From 712ce6b896e9f4d80e0840207dfae4ea91d8c8c6 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Wed, 19 Nov 2025 16:38:46 -0800 Subject: [PATCH 1/3] Support multiple RPC urls for EvmRpc --- CHANGELOG.md | 2 + src/plugins/allPlugins.ts | 160 ++++++++++++++++++++++++++++++------ src/plugins/evmRpc.ts | 17 ++-- test/plugins/evmRpc.test.ts | 32 ++++++-- 4 files changed, 174 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b846898..7ecfaff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: Support multiple RPC urls for EvmRpc plugins. + ## 0.2.4 (2025-11-19) - fixed: Removed defunct Etherscan V1 servers. diff --git a/src/plugins/allPlugins.ts b/src/plugins/allPlugins.ts index b05e3ef..4a880de 100644 --- a/src/plugins/allPlugins.ts +++ b/src/plugins/allPlugins.ts @@ -38,7 +38,9 @@ export const allPlugins = [ // Ethereum family: makeEvmRpc({ pluginId: 'abstract', - url: 'https://api.mainnet.abs.xyz', + urls: [ + 'https://abstract.api.onfinality.io/public' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -49,11 +51,23 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'amoy', - url: 'https://polygon-amoy-bor-rpc.publicnode.com' + urls: [ + 'https://api.zan.top/polygon-amoy', // yellow privacy + 'https://polygon-amoy-public.nodies.app', // yellow privacy + 'https://polygon-amoy.api.onfinality.io/public' // yellow privacy + ] }), makeEvmRpc({ pluginId: 'arbitrum', - url: 'https://arbitrum.drpc.org', + urls: [ + 'https://arbitrum-one-rpc.publicnode.com', // green privacy + 'https://arbitrum.meowrpc.com', // green privacy + 'https://public-arb-mainnet.fastnode.io', // green privacy + 'https://api.zan.top/arb-one', // yellow privacy + 'https://arbitrum-one-public.nodies.app', // yellow privacy + 'https://arbitrum.api.onfinality.io/public', // yellow privacy + 'https://rpc.poolz.finance/arbitrum' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -64,7 +78,16 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'avalanche', - url: 'https://avalanche-c-chain-rpc.publicnode.com', + urls: [ + 'https://0xrpc.io/avax', // green privacy + 'https://avalanche-c-chain-rpc.publicnode.com', // green privacy + 'https://avax.meowrpc.com', // green privacy + 'https://endpoints.omniatech.io/v1/avax/mainnet/public', // green privacy + 'https://api.zan.top/avax-mainnet/ext/bc/C/rpc', // yellow privacy + 'https://avalanche-public.nodies.app/ext/bc/C/rpc', // yellow privacy + 'https://avalanche.api.onfinality.io/public/ext/bc/C/rpc', // yellow privacy + 'https://rpc.poolz.finance/avalanche' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -75,7 +98,15 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'base', - url: 'https://base-rpc.publicnode.com', + urls: [ + 'https://base-rpc.publicnode.com', // green privacy + 'https://base.llamarpc.com', // green privacy + 'https://base.meowrpc.com', // green privacy + 'https://api.zan.top/base-mainnet', // yellow privacy + 'https://base-public.nodies.app', // yellow privacy + 'https://base.api.onfinality.io/public', // yellow privacy + 'https://rpc.poolz.finance/base' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -86,7 +117,22 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'binancesmartchain', - url: 'https://bsc-rpc.publicnode.com', + urls: [ + 'https://binance.llamarpc.com', // green privacy + 'https://bsc-rpc.publicnode.com', // green privacy + 'https://bsc.blockrazor.xyz', // green privacy + 'https://bsc.meowrpc.com', // green privacy + 'https://endpoints.omniatech.io/v1/bsc/mainnet/public', // green privacy + 'https://public-bsc-mainnet.fastnode.io', // green privacy + 'https://0.48.club', // yellow privacy + 'https://api-bsc-mainnet-full.n.dwellir.com/2ccf18bf-2916-4198-8856-42172854353c', // yellow privacy + 'https://api.zan.top/bsc-mainnet', // yellow privacy + 'https://binance-smart-chain-public.nodies.app', // yellow privacy + 'https://bnb.api.onfinality.io/public', // yellow privacy + 'https://go.getblock.io/cc778cdbdf5c4b028ec9456e0e6c0cf3', // yellow privacy + 'https://rpc-bsc.48.club', // yellow privacy + 'https://rpc.poolz.finance/bsc' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -97,14 +143,14 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'bobevm', - url: 'https://rpc.gobob.xyz', + urls: ['https://rpc.gobob.xyz'], // original URL - all chainlist RPCs failed scanAdapters: [ { type: 'etherscan-v1', urls: ['https://explorer.gobob.xyz'] } ] }), makeEvmRpc({ pluginId: 'botanix', - url: 'https://rpc.botanixlabs.com', + urls: ['https://rpc.ankr.com/botanix_mainnet'], // green privacy scanAdapters: [ { type: 'etherscan-v1', @@ -114,7 +160,11 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'celo', - url: 'https://celo-rpc.publicnode.com', + urls: [ + 'https://celo-json-rpc.stakely.io', // green privacy + 'https://celo.api.onfinality.io/public', // yellow privacy + 'https://rpc.ankr.com/celo' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v1', urls: ['https://explorer.celo.org/mainnet'] }, { @@ -126,7 +176,24 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'ethereum', - url: 'https://ethereum-rpc.publicnode.com', + urls: [ + 'https://0xrpc.io/eth', // green privacy + 'https://endpoints.omniatech.io/v1/eth/mainnet/public', // green privacy + 'https://eth.blockrazor.xyz', // green privacy + 'https://eth.llamarpc.com', // green privacy + 'https://eth.meowrpc.com', // green privacy + 'https://eth.merkle.io', // green privacy + 'https://ethereum-json-rpc.stakely.io', // green privacy + 'https://ethereum-rpc.publicnode.com', // green privacy + 'https://go.getblock.io/aefd01aa907c4805ba3c00a9e5b48c6b', // green privacy + 'https://rpc.flashbots.net', // green privacy + 'https://rpc.mevblocker.io', // green privacy + 'https://rpc.payload.de', // green privacy + 'https://api.zan.top/eth-mainnet', // yellow privacy + 'https://eth.api.onfinality.io/public', // yellow privacy + 'https://ethereum-public.nodies.app', // yellow privacy + 'https://rpc.poolz.finance/eth' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -137,31 +204,44 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'ethereumclassic', - url: 'https://geth-at.etc-network.info', + urls: [ + 'https://0xrpc.io/etc', // green privacy + 'https://etc.rivet.link', // green privacy + 'https://ethereum-classic-mainnet.gateway.tatum.io' // green privacy + ], scanAdapters: [ { type: 'etherscan-v1', urls: ['https://etc.blockscout.com'] } ] }), makeEvmRpc({ pluginId: 'ethereumpow', - url: 'https://mainnet.ethereumpow.org' + urls: ['https://mainnet.ethereumpow.org'] // no chainlist RPCs found, keeping original }), makeEvmRpc({ pluginId: 'fantom', - url: 'https://rpc.fantom.network', + urls: [ + 'https://endpoints.omniatech.io/v1/fantom/mainnet/public', // green privacy + 'https://fantom-json-rpc.stakely.io', // green privacy + 'https://api.zan.top/ftm-mainnet', // yellow privacy + 'https://fantom-public.nodies.app', // yellow privacy + 'https://fantom.api.onfinality.io/public' // yellow privacy + ], scanAdapters: [{ type: 'etherscan-v1', urls: ['https://ftmscout.com/'] }] }), makeEvmRpc({ pluginId: 'filecoinfevm', - url: 'https://rpc.ankr.com/filecoin' + urls: [ + 'https://filecoin.chainup.net/rpc/v1', // yellow privacy + 'https://rpc.ankr.com/filecoin' // yellow privacy + ] }), makeEvmRpc({ pluginId: 'filecoinfevmcalibration', - url: 'https://rpc.ankr.com/filecoin_testnet' + urls: ['https://rpc.ankr.com/filecoin_testnet'] // original URL - all chainlist RPCs failed }), makeEvmRpc({ pluginId: 'holesky', - url: 'https://ethereum-holesky-rpc.publicnode.com', + urls: ['https://ethereum-holesky-rpc.publicnode.com'], // original URL - all chainlist RPCs failed scanAdapters: [ { type: 'etherscan-v2', @@ -172,7 +252,7 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'hyperevm', - url: 'https://rpc.hyperliquid.xyz/evm', + urls: ['https://rpc.hyperliquid.xyz/evm'], // no chainlist RPCs found, keeping original scanAdapters: [ { type: 'etherscan-v1', @@ -182,7 +262,15 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'optimism', - url: 'https://optimism-rpc.publicnode.com', + urls: [ + 'https://0xrpc.io/op', // green privacy + 'https://endpoints.omniatech.io/v1/op/mainnet/public', // green privacy + 'https://optimism-rpc.publicnode.com', // green privacy + 'https://public-op-mainnet.fastnode.io', // green privacy + 'https://api.zan.top/opt-mainnet', // yellow privacy + 'https://optimism-public.nodies.app', // yellow privacy + 'https://optimism.api.onfinality.io/public' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -193,7 +281,15 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'polygon', - url: 'https://polygon-bor-rpc.publicnode.com', + urls: [ + 'https://endpoints.omniatech.io/v1/matic/mainnet/public', // green privacy + 'https://polygon-bor-rpc.publicnode.com', // green privacy + 'https://polygon.meowrpc.com', // green privacy + 'https://api.zan.top/polygon-mainnet', // yellow privacy + 'https://polygon-public.nodies.app', // yellow privacy + 'https://polygon.api.onfinality.io/public', // yellow privacy + 'https://rpc.poolz.finance/polygon' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -204,21 +300,31 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'pulsechain', - url: 'https://pulsechain-rpc.publicnode.com', + urls: [ + 'https://pulsechain-rpc.publicnode.com', // green privacy + 'https://rpc.pulsechainrpc.com', // green privacy + 'https://rpc.pulsechainstats.com' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v1', urls: ['https://api.scan.pulsechain.com'] } ] }), makeEvmRpc({ pluginId: 'rsk', - url: 'https://public-node.rsk.co', + urls: ['https://public-node.rsk.co'], // original URL - all chainlist RPCs failed scanAdapters: [ { type: 'etherscan-v1', urls: ['https://rootstock.blockscout.com/'] } ] }), makeEvmRpc({ pluginId: 'sepolia', - url: 'https://ethereum-sepolia-rpc.publicnode.com', + urls: [ + 'https://0xrpc.io/sep', // green privacy + 'https://ethereum-sepolia-rpc.publicnode.com', // green privacy + 'https://api.zan.top/eth-sepolia', // yellow privacy + 'https://eth-sepolia.api.onfinality.io/public', // yellow privacy + 'https://ethereum-sepolia-public.nodies.app' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v2', @@ -229,7 +335,7 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'sonic', - url: 'https://sonic.drpc.org', + urls: ['https://sonic-json-rpc.stakely.io'], // green privacy scanAdapters: [ { type: 'etherscan-v2', @@ -240,7 +346,13 @@ export const allPlugins = [ }), makeEvmRpc({ pluginId: 'zksync', - url: 'https://mainnet.era.zksync.io', + urls: [ + 'https://rpc.ankr.com/zksync_era', // green privacy + 'https://zksync.meowrpc.com', // green privacy + 'https://api.zan.top/zksync-mainnet', // yellow privacy + 'https://go.getblock.io/f76c09905def4618a34946bf71851542', // yellow privacy + 'https://zksync.api.onfinality.io/public' // yellow privacy + ], scanAdapters: [ { type: 'etherscan-v1', diff --git a/src/plugins/evmRpc.ts b/src/plugins/evmRpc.ts index 4a34906..b214ba2 100644 --- a/src/plugins/evmRpc.ts +++ b/src/plugins/evmRpc.ts @@ -1,4 +1,4 @@ -import { createPublicClient, http, parseAbiItem } from 'viem' +import { createPublicClient, fallback, http, parseAbiItem } from 'viem' import { mainnet } from 'viem/chains' import { makeEvents } from 'yavent' @@ -17,8 +17,8 @@ export interface EvmRpcOptions { /** A clean URL for logging */ safeUrl?: string - /** The actual wss connection URL */ - url: string + /** The actual RPC connection URLs (will use fallback transport to try all) */ + urls: string[] /** The scan adapters to use for this plugin. */ scanAdapters?: ScanAdapterConfig[] @@ -32,7 +32,10 @@ const ERC20_TRANSFER_EVENT = parseAbiItem( ) export function makeEvmRpc(opts: EvmRpcOptions): AddressPlugin { - const { pluginId, safeUrl = opts.url, url, scanAdapters } = opts + const { pluginId, urls, scanAdapters } = opts + + // Use random URL for logging if safeUrl not provided + const safeUrl = opts.safeUrl ?? pickRandom(urls) const [on, emit] = makeEvents() @@ -52,9 +55,13 @@ export function makeEvmRpc(opts: EvmRpcOptions): AddressPlugin { // Track subscribed addresses (normalized lowercase address -> original address) const subscribedAddresses = new Map() + // Create fallback transport with all URLs + const transports = urls.map(url => http(url)) + const transport = fallback(transports) + const client = createPublicClient({ chain: mainnet, - transport: http(url) + transport }) client.watchBlocks({ diff --git a/test/plugins/evmRpc.test.ts b/test/plugins/evmRpc.test.ts index ed91a37..64d7e4a 100644 --- a/test/plugins/evmRpc.test.ts +++ b/test/plugins/evmRpc.test.ts @@ -24,9 +24,16 @@ jest.mock('viem', () => { getLogs: mockGetLogs } + const mockHttp = jest.fn(url => ({ url, type: 'http' })) + const mockFallback = jest.fn(transports => ({ + type: 'fallback', + transports + })) + return { createPublicClient: jest.fn(() => mockClient), - http: jest.fn(url => ({ url })), + http: mockHttp, + fallback: mockFallback, parseAbiItem: jest.fn(abiString => ({ abiString })) } }) @@ -80,7 +87,7 @@ describe('evmRpc plugin', function () { plugin = makeEvmRpc({ pluginId: 'test-evm', - url: mockUrl, + urls: [mockUrl], scanAdapters: [ { type: 'etherscan-v1', @@ -97,9 +104,16 @@ describe('evmRpc plugin', function () { test('plugin instantiation', function () { expect(plugin.pluginId).toBe('test-evm') + expect(mockViemLib.http).toHaveBeenCalledWith(mockUrl) + expect(mockViemLib.fallback).toHaveBeenCalled() + const fallbackCall = mockViemLib.fallback.mock.calls[0][0] + expect(fallbackCall).toHaveLength(1) + expect(fallbackCall[0]).toMatchObject({ url: mockUrl, type: 'http' }) expect(mockViemLib.createPublicClient).toHaveBeenCalledWith({ chain: expect.anything(), - transport: expect.anything() + transport: expect.objectContaining({ + type: 'fallback' + }) }) expect(mockClient.watchBlocks).toHaveBeenCalled() }) @@ -388,10 +402,12 @@ describe('evmRpc plugin', function () { errorHandler(new Error('Test error')) // Check that the error was logged - expect(consoleSpy.error).toHaveBeenCalledWith( - 'test-evm (https://ethereum.example.com/rpc):', - 'watchBlocks error', - expect.any(Error) - ) + // The log prefix includes the plugin ID and URL (which is picked randomly from urls array) + expect(consoleSpy.error).toHaveBeenCalled() + const errorCall = consoleSpy.error.mock.calls[0] + expect(errorCall[0]).toContain('test-evm (') + expect(errorCall[0]).toContain(mockUrl) + expect(errorCall[1]).toBe('watchBlocks error') + expect(errorCall[2]).toBeInstanceOf(Error) }) }) From 316ce0c11791757f68af1938cc49db87d7c61423 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Thu, 20 Nov 2025 11:20:21 -0800 Subject: [PATCH 2/3] Track active transport URL for logging --- src/plugins/evmRpc.ts | 49 +++++++++++++++++++++++++++++---- test/plugins/evmRpc.test.ts | 55 ++++++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/plugins/evmRpc.ts b/src/plugins/evmRpc.ts index b214ba2..6f82ee9 100644 --- a/src/plugins/evmRpc.ts +++ b/src/plugins/evmRpc.ts @@ -39,24 +39,40 @@ export function makeEvmRpc(opts: EvmRpcOptions): AddressPlugin { const [on, emit] = makeEvents() - const logPrefix = `${pluginId} (${safeUrl}):` + // Track which URL is currently being used by the fallback transport + let activeUrl: string = safeUrl + + // Create a logger that uses the active URL + const getLogPrefix = (): string => `${pluginId} (${activeUrl}):` const logger: Logger = { log: (...args: unknown[]): void => { - console.log(logPrefix, ...args) + console.log(getLogPrefix(), ...args) }, error: (...args: unknown[]): void => { - console.error(logPrefix, ...args) + console.error(getLogPrefix(), ...args) }, warn: (...args: unknown[]): void => { - console.warn(logPrefix, ...args) + console.warn(getLogPrefix(), ...args) } } // Track subscribed addresses (normalized lowercase address -> original address) const subscribedAddresses = new Map() + // Create a map to track which URL corresponds to which transport instance + const transportUrlMap = new Map() + // Create fallback transport with all URLs - const transports = urls.map(url => http(url)) + const transports = urls.map(url => { + const httpTransport = http(url) + // Wrap the transport factory to track URL mapping + return (config: any) => { + const transportInstance = httpTransport(config) + // Store the mapping from transport instance to URL + transportUrlMap.set(transportInstance, url) + return transportInstance + } + }) const transport = fallback(transports) const client = createPublicClient({ @@ -64,6 +80,29 @@ export function makeEvmRpc(opts: EvmRpcOptions): AddressPlugin { transport }) + // Access the transport's onResponse callback after client creation to track which URL was used + // The transport is created internally, so we need to access it through the client's transport + const fallbackTransport = client.transport as any + if (fallbackTransport?.onResponse != null) { + fallbackTransport.onResponse( + ({ + transport: usedTransport, + status + }: { + transport: any + status: 'success' | 'error' + }) => { + // When a transport succeeds, update the active URL + if (status === 'success' && usedTransport != null) { + const url = transportUrlMap.get(usedTransport) + if (url != null) { + activeUrl = url + } + } + } + ) + } + client.watchBlocks({ includeTransactions: true, emitMissed: true, diff --git a/test/plugins/evmRpc.test.ts b/test/plugins/evmRpc.test.ts index 64d7e4a..0ee25cb 100644 --- a/test/plugins/evmRpc.test.ts +++ b/test/plugins/evmRpc.test.ts @@ -19,17 +19,43 @@ jest.mock('viem', () => { const mockWatchBlocks = jest.fn() const mockGetLogs = jest.fn() + // Create a mock transport instance that can track URL + const createMockTransportInstance = (url: string): any => ({ + url, + type: 'http', + request: jest.fn() + }) + + const mockHttp = jest.fn((url: string) => { + // Return a factory function that creates a transport instance + return (config: any) => createMockTransportInstance(url) + }) + + // Mock fallback transport with onResponse support + let mockFallbackTransportInstance: any = null + + const mockFallback = jest.fn((transports: any[]) => { + // Create transport instances from factories + const transportInstances = transports.map((transportFactory: any) => + transportFactory({ chain: {}, retryCount: 0 }) + ) + + mockFallbackTransportInstance = { + type: 'fallback', + transports: transportInstances, + onResponse: jest.fn() + } + return mockFallbackTransportInstance + }) + const mockClient = { watchBlocks: mockWatchBlocks, - getLogs: mockGetLogs + getLogs: mockGetLogs, + get transport() { + return mockFallbackTransportInstance + } } - const mockHttp = jest.fn(url => ({ url, type: 'http' })) - const mockFallback = jest.fn(transports => ({ - type: 'fallback', - transports - })) - return { createPublicClient: jest.fn(() => mockClient), http: mockHttp, @@ -42,11 +68,12 @@ jest.mock('viem/chains', () => ({ mainnet: { id: 1, name: 'Mainnet' } })) -// Access the mocked client - Using any type to avoid TS errors with Jest mocks +// Access the mocked functions - Using any type to avoid TS errors with Jest mocks // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockViemLib: any = jest.requireMock('viem') +// Get the mocked client instance - will be recreated in beforeEach // eslint-disable-next-line @typescript-eslint/no-explicit-any -const mockClient: any = mockViemLib.createPublicClient() +let mockClient: any describe('evmRpc plugin', function () { const TEST_ETH_ADDRESS = '0xF5335367A46c2484f13abd051444E39775EA7b60' @@ -74,6 +101,9 @@ describe('evmRpc plugin', function () { beforeEach(() => { jest.clearAllMocks() + // Get the mocked client instance + mockClient = mockViemLib.createPublicClient() + // Reset mock functions mockClient.watchBlocks.mockImplementation( ({ onBlock }: { onBlock: any }) => { @@ -108,7 +138,8 @@ describe('evmRpc plugin', function () { expect(mockViemLib.fallback).toHaveBeenCalled() const fallbackCall = mockViemLib.fallback.mock.calls[0][0] expect(fallbackCall).toHaveLength(1) - expect(fallbackCall[0]).toMatchObject({ url: mockUrl, type: 'http' }) + // The transport factory is a function, so we check it was called correctly + expect(typeof fallbackCall[0]).toBe('function') expect(mockViemLib.createPublicClient).toHaveBeenCalledWith({ chain: expect.anything(), transport: expect.objectContaining({ @@ -116,6 +147,10 @@ describe('evmRpc plugin', function () { }) }) expect(mockClient.watchBlocks).toHaveBeenCalled() + // Verify onResponse was set up (if transport exists) + if (mockClient.transport != null) { + expect(mockClient.transport.onResponse).toHaveBeenCalled() + } }) test('subscribe should return true', async function () { From ebc79886ed1531f117ba3e676ba3819b77b7c1ab Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Wed, 26 Nov 2025 11:09:13 -0800 Subject: [PATCH 3/3] Remove safeUrl and add URL sanitization for logging - Remove safeUrl parameter from BlockbookOptions and EvmRpcOptions interfaces - Add sanitizeUrlForLogging utility function that removes API keys from URLs - Use sanitization function in blockbook plugin (API keys are added to URLs) - Stub sanitization function in evmRpc plugin with TODO (API keys not yet used for RPC URLs) - Remove safeUrl usage from allPlugins.ts --- src/plugins/allPlugins.ts | 13 ++++++------- src/plugins/blockbook.ts | 26 +++++++++++++++++--------- src/plugins/evmRpc.ts | 24 ++++++++++++++++-------- test/plugins/blockbook.test.ts | 6 ++++-- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/plugins/allPlugins.ts b/src/plugins/allPlugins.ts index 4a880de..94c0651 100644 --- a/src/plugins/allPlugins.ts +++ b/src/plugins/allPlugins.ts @@ -7,8 +7,7 @@ import { makeFakePlugin } from './fakePlugin' function makeNowNode(opts: BlockbookOptions): AddressPlugin { return makeBlockbook({ ...opts, - safeUrl: opts.url, - url: opts.url + '/' + serverConfig.nowNodesApiKey + nowNodesApiKey: serverConfig.nowNodesApiKey }) } @@ -16,23 +15,23 @@ export const allPlugins = [ // Bitcoin family: makeNowNode({ pluginId: 'bitcoin', - url: 'wss://btcbook.nownodes.io/wss' + url: 'wss://btcbook.nownodes.io/wss/{nowNodesApiKey}' }), makeNowNode({ pluginId: 'bitcoincash', - url: 'wss://bchbook.nownodes.io/wss' + url: 'wss://bchbook.nownodes.io/wss/{nowNodesApiKey}' }), makeNowNode({ pluginId: 'dogecoin', - url: 'wss://dogebook.nownodes.io/wss' + url: 'wss://dogebook.nownodes.io/wss/{nowNodesApiKey}' }), makeNowNode({ pluginId: 'litecoin', - url: 'wss://ltcbook.nownodes.io/wss' + url: 'wss://ltcbook.nownodes.io/wss/{nowNodesApiKey}' }), makeNowNode({ pluginId: 'qtum', - url: 'wss://qtum-blockbook.nownodes.io/wss' + url: 'wss://qtum-blockbook.nownodes.io/wss/{nowNodesApiKey}' }), // Ethereum family: diff --git a/src/plugins/blockbook.ts b/src/plugins/blockbook.ts index 7de576c..700c036 100644 --- a/src/plugins/blockbook.ts +++ b/src/plugins/blockbook.ts @@ -32,11 +32,11 @@ const pluginErrorCounter = new Counter({ export interface BlockbookOptions { pluginId: string - /** A clean URL for logging */ - safeUrl?: string - /** The actual connection URL */ url: string + + /** Optional API key to replace {nowNodesApiKey} template in URL */ + nowNodesApiKey?: string } interface Connection { @@ -47,10 +47,18 @@ interface Connection { } export function makeBlockbook(opts: BlockbookOptions): AddressPlugin { - const { pluginId, safeUrl = opts.url, url } = opts + const { pluginId, url, nowNodesApiKey } = opts const [on, emit] = makeEvents() + // Replace template with actual API key for connection URL + const connectionUrl = + nowNodesApiKey != null + ? url.replace('{nowNodesApiKey}', nowNodesApiKey) + : url + // Use original URL (with template) for logging - no sanitization needed + const logUrl = url + const addressToConnection = new Map() const connections: Connection[] = [] // Map of address to unconfirmed txids for tracking mempool transactions @@ -80,7 +88,7 @@ export function makeBlockbook(opts: BlockbookOptions): AddressPlugin { } })() - const logPrefix = `${pluginId} (${safeUrl}):` + const logPrefix = `${pluginId} (${logUrl}):` const logger = { log: (...args: unknown[]): void => { console.log(logPrefix, ...args) @@ -94,7 +102,7 @@ export function makeBlockbook(opts: BlockbookOptions): AddressPlugin { } function makeConnection(): Connection { - const ws = new WebSocket(url) + const ws = new WebSocket(connectionUrl) const codec = blockbookProtocol.makeClientCodec({ handleError, async handleSend(text) { @@ -111,12 +119,12 @@ export function makeBlockbook(opts: BlockbookOptions): AddressPlugin { }) const socketReady = new Promise(resolve => { ws.on('open', () => { - pluginConnectionCounter.inc({ pluginId, url: safeUrl }) + pluginConnectionCounter.inc({ pluginId, url: logUrl }) resolve() }) }) ws.on('close', () => { - pluginDisconnectionCounter.inc({ pluginId, url: safeUrl }) + pluginDisconnectionCounter.inc({ pluginId, url: logUrl }) if (connection === blockConnection) { // If this was the block connection, re-init it. @@ -207,7 +215,7 @@ export function makeBlockbook(opts: BlockbookOptions): AddressPlugin { function handleError(error: unknown): void { // Log to Prometheus: - pluginErrorCounter.inc({ pluginId, url: safeUrl }) + pluginErrorCounter.inc({ pluginId, url: logUrl }) logger.warn('WebSocket error:', error) } diff --git a/src/plugins/evmRpc.ts b/src/plugins/evmRpc.ts index 6f82ee9..335b022 100644 --- a/src/plugins/evmRpc.ts +++ b/src/plugins/evmRpc.ts @@ -15,8 +15,6 @@ import { export interface EvmRpcOptions { pluginId: string - /** A clean URL for logging */ - safeUrl?: string /** The actual RPC connection URLs (will use fallback transport to try all) */ urls: string[] @@ -34,16 +32,14 @@ const ERC20_TRANSFER_EVENT = parseAbiItem( export function makeEvmRpc(opts: EvmRpcOptions): AddressPlugin { const { pluginId, urls, scanAdapters } = opts - // Use random URL for logging if safeUrl not provided - const safeUrl = opts.safeUrl ?? pickRandom(urls) - const [on, emit] = makeEvents() // Track which URL is currently being used by the fallback transport - let activeUrl: string = safeUrl + let activeUrl: string = pickRandom(urls) - // Create a logger that uses the active URL - const getLogPrefix = (): string => `${pluginId} (${activeUrl}):` + // Create a logger that uses the active URL (sanitized for logging) + const getLogPrefix = (): string => + `${pluginId} (${sanitizeUrlForLogging(activeUrl)}):` const logger: Logger = { log: (...args: unknown[]): void => { console.log(getLogPrefix(), ...args) @@ -274,3 +270,15 @@ function getScanAdapter( return makeEtherscanV2ScanAdapter(scanAdapterConfig, logger) } } + +/** + * Sanitizes a URL for safe logging by removing sensitive information like API keys. + * TODO: Implement URL sanitization once API keys are used for RPC URLs. + * + * @param url - The URL to sanitize + * @returns A sanitized URL safe for logging + */ +function sanitizeUrlForLogging(url: string): string { + // TODO: We'll clean URLs once API keys are used for RPC URLs + return url +} diff --git a/test/plugins/blockbook.test.ts b/test/plugins/blockbook.test.ts index 2851f69..63ce4e5 100644 --- a/test/plugins/blockbook.test.ts +++ b/test/plugins/blockbook.test.ts @@ -27,7 +27,7 @@ describe('blockbook plugin', function () { const host = 'localhost' const port = Math.floor(Math.random() * 1000 + 5000) const mockBlockbookUrl = USE_REAL_BLOCKBOOK_SERVER - ? `wss://btcbook.nownodes.io/wss/${serverConfig.nowNodesApiKey}` + ? `wss://btcbook.nownodes.io/wss/{nowNodesApiKey}` : `ws://${host}:${port}` const blockbookWsServer = new WebSocket.Server({ @@ -169,7 +169,9 @@ describe('blockbook plugin', function () { beforeEach(() => { plugin = makeBlockbook({ pluginId: 'test', - url: mockBlockbookUrl + url: mockBlockbookUrl, + // For testing real blockbook server, we need to provide the API key + nowNodesApiKey: serverConfig.nowNodesApiKey }) }) afterEach(() => {