diff --git a/src/common/serviceKeys.ts b/src/common/serviceKeys.ts new file mode 100644 index 000000000..b9c024017 --- /dev/null +++ b/src/common/serviceKeys.ts @@ -0,0 +1,52 @@ +import { ServiceKeys } from './types' +import { pickRandom } from './utils' + +/** + * Retrieves a random service key (API key) for a given host index. + * + * @param serviceKeys - An object mapping host strings to arrays of API keys + * @param index - The host string to look up (typically obtained from getServiceKeyIndex) + * @returns A randomly selected API key string, or undefined if no keys are available for the given index + * + * @example + * ```typescript + * const serviceKeys = { + * 'api.example.com': ['key1', 'key2', 'key3'] + * } + * const key = getRandomServiceKey(serviceKeys, 'api.example.com') + * // Returns one of: 'key1', 'key2', or 'key3' + * ``` + */ +export function getRandomServiceKey( + serviceKeys: ServiceKeys, + index: string +): string | undefined { + const keys = serviceKeys[index] + if (keys == null || keys.length === 0) return undefined + return pickRandom(keys, 1)[0] +} + +/** + * Extracts the host from a URL string to use as an index for ServiceKeys. + * + * This function normalizes URLs by constructing a proper URL object, which handles + * both full URLs and host-only strings. The host is used as the key to look up + * service keys in the ServiceKeys object. + * + * The reason for this function is to define the index or key for the + * ServiceKey object from a URL string to determine the relevant API keys for + * the service provider. + * + * @param urlString - A URL string or host string (e.g., 'https://api.example.com' or 'api.example.com') + * @returns The host portion of the URL (e.g., 'api.example.com') + * + * @example + * ```typescript + * getServiceKeyIndex('https://api.example.com/path') // Returns 'api.example.com' + * getServiceKeyIndex('api.example.com') // Returns 'api.example.com' + * ``` + */ +export function getServiceKeyIndex(urlString: string): string { + const url = new URL(urlString, `https://${urlString}`) + return url.host +} diff --git a/src/common/types.ts b/src/common/types.ts index f5a792f23..3209ab072 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -205,3 +205,8 @@ export const asEdgeToken = asObject({ export const asInfoServerTokens = asObject({ infoServerTokens: asMaybe(asArray(asUnknown)) }) + +export interface ServiceKeys { + [host: string]: string[] +} +export const asServiceKeys: Cleaner = asObject(asArray(asString)) diff --git a/src/ethereum/EthereumTools.ts b/src/ethereum/EthereumTools.ts index af6dfbeb5..4d9a2308b 100644 --- a/src/ethereum/EthereumTools.ts +++ b/src/ethereum/EthereumTools.ts @@ -29,6 +29,7 @@ import { } from '../common/utils' import { ethereumPlugins } from './ethereumInfos' import { + asEthereumInitOptions, asEthereumPrivateKeys, asSafeEthWalletInfo, EthereumInfoPayload, @@ -50,7 +51,7 @@ export class EthereumTools implements EdgeCurrencyTools { this.currencyInfo = currencyInfo this.io = io this.networkInfo = networkInfo - this.initOptions = initOptions + this.initOptions = asEthereumInitOptions(initOptions) } async getDisplayPrivateKey( diff --git a/src/ethereum/ethereumTypes.ts b/src/ethereum/ethereumTypes.ts index 3e91bd132..3ec48e3f0 100644 --- a/src/ethereum/ethereumTypes.ts +++ b/src/ethereum/ethereumTypes.ts @@ -18,29 +18,50 @@ import { EdgeSpendInfo } from 'edge-core-js/types' import { asIntegerString, asSafeCommonWalletInfo, + asServiceKeys, + ServiceKeys, WalletConnectPayload } from '../common/types' import { FeeAlgorithmConfig } from './feeAlgorithms/feeAlgorithmTypes' import type { NetworkAdapterConfig } from './networkAdapters/networkAdapterTypes' export interface EthereumInitOptions { + serviceKeys: ServiceKeys + infuraProjectId?: string + + /** @deprecated Use serviceKeys instead */ alchemyApiKey?: string + /** @deprecated Use serviceKeys instead */ amberdataApiKey?: string + /** @deprecated Use serviceKeys instead */ blockchairApiKey?: string + /** @deprecated Use serviceKeys instead */ blockcypherApiKey?: string + /** @deprecated Use serviceKeys instead */ drpcApiKey?: string - /** For Etherscan v2 API */ + /** For Etherscan v2 API + * @deprecated Use serviceKeys instead + */ etherscanApiKey?: string | string[] - /** For bespoke scan APIs unsupported by Etherscan v2 API (e.g. fantomscan) */ + /** For bespoke scan APIs unsupported by Etherscan v2 API (e.g. fantomscan) + * @deprecated Use serviceKeys instead + */ evmScanApiKey?: string | string[] + /** @deprecated Use serviceKeys instead */ gasStationApiKey?: string - infuraProjectId?: string + /** @deprecated Use serviceKeys instead */ nowNodesApiKey?: string + /** @deprecated Use serviceKeys instead */ poktPortalApiKey?: string + /** @deprecated Use serviceKeys instead */ quiknodeApiKey?: string } export const asEthereumInitOptions = asObject({ + serviceKeys: asOptional(asServiceKeys, () => ({})), + infuraProjectId: asOptional(asString), + + // Deprecated: alchemyApiKey: asOptional(asString), amberdataApiKey: asOptional(asString), blockchairApiKey: asOptional(asString), @@ -49,7 +70,6 @@ export const asEthereumInitOptions = asObject({ etherscanApiKey: asOptional(asEither(asString, asArray(asString))), evmScanApiKey: asOptional(asEither(asString, asArray(asString))), gasStationApiKey: asOptional(asString), - infuraProjectId: asOptional(asString), nowNodesApiKey: asOptional(asString), poktPortalApiKey: asOptional(asString), quiknodeApiKey: asOptional(asString) diff --git a/src/ethereum/fees/feeProviders.ts b/src/ethereum/fees/feeProviders.ts index 586b7f019..32fc0faf0 100644 --- a/src/ethereum/fees/feeProviders.ts +++ b/src/ethereum/fees/feeProviders.ts @@ -8,6 +8,10 @@ import { } from 'edge-core-js/types' import { fetchInfo } from '../../common/network' +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { hexToDecimal, pickRandom } from '../../common/utils' import { GAS_PRICE_SANITY_CHECK, @@ -346,36 +350,55 @@ export const getEvmScanApiKey = ( log: EdgeLog, serverUrl: string ): string | string[] | undefined => { - const { evmScanApiKey, etherscanApiKey, bscscanApiKey, polygonscanApiKey } = - initOptions + const { + evmScanApiKey, + etherscanApiKey, + bscscanApiKey, + polygonscanApiKey, + serviceKeys + } = initOptions const { currencyCode } = info + const serviceKey = getRandomServiceKey( + serviceKeys, + getServiceKeyIndex(serverUrl) + ) + + if (serviceKey != null) return serviceKey // If we have a server URL and it's etherscan.io, use the Ethereum API key if (serverUrl.includes('etherscan.io')) { if (etherscanApiKey == null) throw new Error(`Missing etherscanApiKey for etherscan.io`) + log.warn( + "INIT OPTION 'etherscanApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) return etherscanApiKey } - if (evmScanApiKey != null) return evmScanApiKey + if (evmScanApiKey != null) { + log.warn( + "INIT OPTION 'evmScanApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) + return evmScanApiKey + } // For networks that don't support Etherscan v2, fall back to network-specific keys if (currencyCode === 'ETH' && etherscanApiKey != null) { log.warn( - "INIT OPTION 'etherscanApiKey' IS DEPRECATED. USE 'evmScanApiKey' INSTEAD" + "INIT OPTION 'etherscanApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" ) return etherscanApiKey } if (currencyCode === 'BNB' && bscscanApiKey != null) { log.warn( - "INIT OPTION 'bscscanApiKey' IS DEPRECATED. USE 'evmScanApiKey' INSTEAD" + "INIT OPTION 'bscscanApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" ) return bscscanApiKey } if (currencyCode === 'POL' && polygonscanApiKey != null) { log.warn( - "INIT OPTION 'polygonscanApiKey' IS DEPRECATED. USE 'evmScanApiKey' INSTEAD" + "INIT OPTION 'polygonscanApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" ) return polygonscanApiKey } diff --git a/src/ethereum/networkAdapters/AmberdataAdapter.ts b/src/ethereum/networkAdapters/AmberdataAdapter.ts index 24c368eb6..db537c65a 100644 --- a/src/ethereum/networkAdapters/AmberdataAdapter.ts +++ b/src/ethereum/networkAdapters/AmberdataAdapter.ts @@ -1,4 +1,7 @@ -import { base58ToHexAddress } from '../../tron/tronUtils' +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { EthereumNetworkUpdate } from '../EthereumNetwork' import { asRpcResultString } from '../ethereumTypes' import { NetworkAdapter } from './networkAdapterTypes' @@ -52,10 +55,22 @@ export class AmberdataAdapter extends NetworkAdapter { method: string, params: string[] = [] ): Promise { - const { amberdataApiKey = '' } = this.ethEngine.initOptions + const { amberdataApiKey, serviceKeys } = this.ethEngine.initOptions + + return await this.serialServers(async url => { + let apiKey = getRandomServiceKey(serviceKeys, getServiceKeyIndex(url)) + + if (apiKey == null && amberdataApiKey != null) { + this.ethEngine.log.warn( + "INIT OPTION 'amberdataApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) + apiKey = amberdataApiKey + } + + if (apiKey == null) { + throw new Error('Missing API key') + } - return await this.serialServers(async baseUrl => { - const url = `${this.config.servers[0]}` const body = { jsonrpc: '2.0', method: method, @@ -65,7 +80,7 @@ export class AmberdataAdapter extends NetworkAdapter { const response = await this.ethEngine.fetchCors(url, { headers: { 'x-amberdata-blockchain-id': this.config.amberdataBlockchainId, - 'x-api-key': amberdataApiKey, + 'x-api-key': apiKey, 'Content-Type': 'application/json' }, method: 'POST', @@ -82,14 +97,27 @@ export class AmberdataAdapter extends NetworkAdapter { // TODO: Clean return type private async fetchGetAmberdataApi(path: string): Promise { - const { amberdataApiKey = '' } = this.ethEngine.initOptions + const { amberdataApiKey, serviceKeys } = this.ethEngine.initOptions return await this.serialServers(async baseUrl => { - const url = `${base58ToHexAddress}${path}` + let apiKey = getRandomServiceKey(serviceKeys, getServiceKeyIndex(baseUrl)) + + if (apiKey == null && amberdataApiKey != null) { + this.ethEngine.log.warn( + "INIT OPTION 'amberdataApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) + apiKey = amberdataApiKey + } + + if (apiKey == null) { + throw new Error('Missing API key') + } + + const url = `${baseUrl}${path}` const response = await this.ethEngine.fetchCors(url, { headers: { 'x-amberdata-blockchain-id': this.config.amberdataBlockchainId, - 'x-api-key': amberdataApiKey + 'x-api-key': apiKey } }) if (!response.ok) { diff --git a/src/ethereum/networkAdapters/BlockbookWsAdapter.ts b/src/ethereum/networkAdapters/BlockbookWsAdapter.ts index a94dfaabd..21c6510f1 100644 --- a/src/ethereum/networkAdapters/BlockbookWsAdapter.ts +++ b/src/ethereum/networkAdapters/BlockbookWsAdapter.ts @@ -2,6 +2,10 @@ import { asBoolean, asJSON, asMaybe, asObject, asString } from 'cleaners' import WebSocket from 'isomorphic-ws' import { makePeriodicTask, PeriodicTask } from '../../common/periodicTask' +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { pickRandomOne } from '../../common/utils' import { EthereumEngine } from '../EthereumEngine' import { EthereumInitOptions } from '../ethereumTypes' @@ -208,9 +212,19 @@ export class BlockbookWsAdapter extends NetworkAdapter while (servers.length > 0) { const server = pickRandomOne(this.servers) + let apiKey = getRandomServiceKey( + this.ethEngine.initOptions.serviceKeys, + getServiceKeyIndex(server.url) + ) if (server.keyType != null) { - const apiKey = this.ethEngine.initOptions[server.keyType] + if (apiKey == null) { + apiKey = this.ethEngine.initOptions[server.keyType] + if (apiKey != null) + this.ethEngine.log.warn( + `INIT OPTION '${server.keyType}' IS DEPRECATED. USE 'serviceKeys' INSTEAD` + ) + } // Check for missing API key if (apiKey == null) { diff --git a/src/ethereum/networkAdapters/BlockchairAdapter.ts b/src/ethereum/networkAdapters/BlockchairAdapter.ts index 3d0daf1e6..c3c96d3c3 100644 --- a/src/ethereum/networkAdapters/BlockchairAdapter.ts +++ b/src/ethereum/networkAdapters/BlockchairAdapter.ts @@ -1,3 +1,7 @@ +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { safeErrorMessage } from '../../common/utils' import { EthereumNetworkUpdate } from '../EthereumNetwork' import { @@ -88,13 +92,24 @@ export class BlockchairAdapter extends NetworkAdapter { path: string, includeKey: boolean = false ): Promise { - const { blockchairApiKey } = this.ethEngine.initOptions + const { blockchairApiKey, serviceKeys } = this.ethEngine.initOptions return await this.serialServers(async baseUrl => { - const keyParam = - includeKey && blockchairApiKey != null ? `&key=${blockchairApiKey}` : '' - const url = `${baseUrl}${path}` - const response = await this.ethEngine.fetchCors(`${url}${keyParam}`) + let apiKey: string | undefined + + if (includeKey) { + apiKey = getRandomServiceKey(serviceKeys, getServiceKeyIndex(baseUrl)) + + if (apiKey == null && blockchairApiKey != null) { + this.ethEngine.log.warn( + "INIT OPTION 'blockchairApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) + apiKey = blockchairApiKey + } + } + + const url = `${baseUrl}${path}${apiKey != null ? `&key=${apiKey}` : ''}` + const response = await this.ethEngine.fetchCors(url) if (!response.ok) { const resBody = await response.text() this.throwError(response, 'fetchGetBlockchair', url, resBody) diff --git a/src/ethereum/networkAdapters/BlockcypherAdapter.ts b/src/ethereum/networkAdapters/BlockcypherAdapter.ts index 353f4112b..d86537ebd 100644 --- a/src/ethereum/networkAdapters/BlockcypherAdapter.ts +++ b/src/ethereum/networkAdapters/BlockcypherAdapter.ts @@ -1,6 +1,10 @@ import { EdgeTransaction } from 'edge-core-js/types' import parse from 'url-parse' +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { BroadcastResults } from '../EthereumNetwork' import { NetworkAdapter } from './networkAdapterTypes' @@ -50,13 +54,21 @@ export class BlockcypherAdapter extends NetworkAdapter body: any, baseUrl: string ): Promise { - const { blockcypherApiKey } = this.ethEngine.initOptions - let apiKey = '' - if (blockcypherApiKey != null && blockcypherApiKey.length > 5) { - apiKey = '&token=' + blockcypherApiKey + const { blockcypherApiKey, serviceKeys } = this.ethEngine.initOptions + let apiKey = getRandomServiceKey(serviceKeys, getServiceKeyIndex(baseUrl)) + + if ( + apiKey == null && + blockcypherApiKey != null && + blockcypherApiKey.length > 5 + ) { + apiKey = blockcypherApiKey + this.ethEngine.log.warn( + "INIT OPTION 'blockcypherApiKey' IS DEPRECATED. USE 'serviceKeys' INSTEAD" + ) } - const url = `${baseUrl}/${cmd}${apiKey}` + const url = `${baseUrl}/${cmd}${apiKey != null ? `&token=${apiKey}` : ''}` const response = await this.ethEngine.fetchCors(url, { headers: { Accept: 'application/json', diff --git a/src/ethereum/networkAdapters/RpcAdapter.ts b/src/ethereum/networkAdapters/RpcAdapter.ts index 165e738cd..c22c01571 100644 --- a/src/ethereum/networkAdapters/RpcAdapter.ts +++ b/src/ethereum/networkAdapters/RpcAdapter.ts @@ -3,6 +3,10 @@ import { EdgeTransaction } from 'edge-core-js/types' import { ethers } from 'ethers' import parse from 'url-parse' +import { + getRandomServiceKey, + getServiceKeyIndex +} from '../../common/serviceKeys' import { asMaybeContractLocation } from '../../common/tokenHelpers' import { hexToDecimal, @@ -433,15 +437,34 @@ export class RpcAdapter extends NetworkAdapter { private addRpcApiKey(url: string): string { const regex = /{{(.*?)}}/g const match = regex.exec(url) + if (match != null) { const key = match[1] - const cleanKey = asEthereumInitKeys(key) - const apiKey = this.ethEngine.initOptions[cleanKey] + + // try service keys first + const serviceKeyIndex = getServiceKeyIndex(url) + let apiKey = getRandomServiceKey( + this.ethEngine.initOptions.serviceKeys, + serviceKeyIndex + ) + + // fall back to deprecated keys + if (apiKey == null) { + const cleanKey = asEthereumInitKeys(key) + const cleanApiKey = this.ethEngine.initOptions[cleanKey] + if (cleanApiKey === 'string') { + apiKey = cleanApiKey + this.ethEngine.log.warn( + `INIT OPTION '${cleanKey}' IS DEPRECATED. USE 'serviceKeys' INSTEAD` + ) + } + } + if (typeof apiKey === 'string') { url = url.replace(match[0], apiKey) } else if (apiKey == null) { throw new Error( - `Missing ${cleanKey} in 'initOptions' for ${this.ethEngine.currencyInfo.pluginId}` + `Missing '${serviceKeyIndex}' in 'initOptions.serviceKeys' for ${this.ethEngine.currencyInfo.pluginId}` ) } else { throw new Error('Incorrect apikey type for RPC') diff --git a/test/engine/feeProvider.test.ts b/test/engine/feeProvider.test.ts index afa03bd1a..b40dbe2f5 100644 --- a/test/engine/feeProvider.test.ts +++ b/test/engine/feeProvider.test.ts @@ -33,11 +33,13 @@ describe(`FTM Network Fees`, function () { fetch, ftmCurrencyInfo, { - evmScanApiKey: [ - 'EG16P5AF5FNJ3XR8ICP3UAYHT68G53TAKU', - '63YA67UBCWPG6SEREC9GNRRR31SDPGSQY9', - 'D925MHYVPJH3ZBSJKES5EFC876FFMW3ZHX' - ] + serviceKeys: { + 'api.etherscan.io': [ + 'EG16P5AF5FNJ3XR8ICP3UAYHT68G53TAKU', + '63YA67UBCWPG6SEREC9GNRRR31SDPGSQY9', + 'D925MHYVPJH3ZBSJKES5EFC876FFMW3ZHX' + ] + } }, fakeLog, ftmNetworkInfo