diff --git a/packages/bitcore-node/src/modules/moralis/api/csp.ts b/packages/bitcore-node/src/modules/moralis/api/csp.ts index 5b8fc4fbcb6..b949d9030ad 100644 --- a/packages/bitcore-node/src/modules/moralis/api/csp.ts +++ b/packages/bitcore-node/src/modules/moralis/api/csp.ts @@ -1,5 +1,4 @@ import os from 'os'; -import { Web3 } from '@bitpay-labs/crypto-wallet-core'; import { LRUCache } from 'lru-cache'; import request from 'request'; import config from '../../../config'; @@ -11,7 +10,14 @@ import { WalletAddressStorage } from '../../../models/walletAddress'; import { BaseEVMStateProvider, BuildWalletTxsStreamParams } from '../../../providers/chain-state/evm/api/csp'; import { EVMBlockStorage } from '../../../providers/chain-state/evm/models/block'; import { EVMTransactionStorage } from '../../../providers/chain-state/evm/models/transaction'; -import { EVMTransactionJSON, GethTraceCall, IEVMBlock, IEVMTransactionTransformed, Transaction } from '../../../providers/chain-state/evm/types'; +import { EVMTransactionJSON, IEVMBlock, IEVMTransactionTransformed } from '../../../providers/chain-state/evm/types'; +import { + buildMoralisQueryString, + formatMoralisChainId, + transformMoralisQueryParams, + transformMoralisTokenTransfer, + transformMoralisTransaction +} from '../../../providers/chain-state/external/adapters/moralis-utils'; import { ExternalApiStream } from '../../../providers/chain-state/external/streams/apiStream'; import { IBlock } from '../../../types/Block'; import { ChainId, ChainNetwork } from '../../../types/ChainNetwork'; @@ -242,8 +248,8 @@ export class MoralisStateProvider extends BaseEVMStateProvider { throw new Error('Invalid chainId'); } - const query = this._transformQueryParams({ chainId, args: { date } }); - const queryStr = this._buildQueryString(query); + const query = transformMoralisQueryParams({ chainId, args: { date } }); + const queryStr = buildMoralisQueryString(query); return new Promise((resolve, reject) => { request({ @@ -267,7 +273,7 @@ export class MoralisStateProvider extends BaseEVMStateProvider { async _getTransactionFromMoralis(params: StreamTransactionParams & ChainId) { const { chain, network, chainId, txId } = params; - const query = this._buildQueryString({ chain: chainId, include: 'internal_transactions' }); + const query = buildMoralisQueryString({ chain: formatMoralisChainId(chainId), include: 'internal_transactions' }); return new Promise((resolve, reject) => { request({ @@ -286,7 +292,7 @@ export class MoralisStateProvider extends BaseEVMStateProvider { if (tx.message === 'No transaction found') { return resolve(null); } - return resolve(this._transformTransaction({ chain, network, ...tx })); + return resolve(transformMoralisTransaction({ chain, network, ...tx })); }); }); } @@ -304,16 +310,16 @@ export class MoralisStateProvider extends BaseEVMStateProvider { throw new Error('Invalid chainId'); } - const query = this._transformQueryParams({ chainId, args }); // throws if no chain or network - const queryStr = this._buildQueryString({ + const query = transformMoralisQueryParams({ chainId, args }); // throws if no chain or network + const queryStr = buildMoralisQueryString({ ...query, - order: args.order || 'DESC', // default to descending order + order: args.order ?? query.order ?? 'DESC', // preserve direction-derived order, default to descending limit: args.pageSize || 10, // limit per request/page. total limit (args.limit) is checked in apiStream._read() include: 'internal_transactions' }); args.transform = (tx) => { - const _tx: any = this._transformTransaction({ chain, network, ...tx }); - const confirmations = this._calculateConfirmations(tx, args.tipHeight); + const _tx: any = transformMoralisTransaction({ chain, network, ...tx }); + const confirmations = this._calculateConfirmations(_tx, args.tipHeight); return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }) as EVMTransactionJSON; }; @@ -335,16 +341,16 @@ export class MoralisStateProvider extends BaseEVMStateProvider { throw new Error('Invalid chainId'); } - const queryTransform = this._transformQueryParams({ chainId, args }); // throws if no chain or network - const queryStr = this._buildQueryString({ + const queryTransform = transformMoralisQueryParams({ chainId, args }); // throws if no chain or network + const queryStr = buildMoralisQueryString({ ...queryTransform, - order: args.order || 'DESC', // default to descending order + order: args.order ?? queryTransform.order ?? 'DESC', // preserve direction-derived order, default to descending limit: args.pageSize || 10, // limit per request/page. total limit (args.limit) is checked in apiStream._read() contract_addresses: [tokenAddress], }); args.transform = (tx) => { - const _tx: any = this._transformTokenTransfer({ chain, network, ...tx }); - const confirmations = this._calculateConfirmations(tx, args.tipHeight); + const _tx: any = transformMoralisTokenTransfer({ chain, network, ...tx }); + const confirmations = this._calculateConfirmations(_tx, args.tipHeight); return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }) as EVMTransactionJSON; }; @@ -355,96 +361,6 @@ export class MoralisStateProvider extends BaseEVMStateProvider { ); } - private _transformTransaction(tx) { - const txid = tx.hash || tx.transaction_hash; // erc20 transfer txs have transaction_hash - try { - const transformed = { - chain: tx.chain, - network: tx.network, - txid, - blockHeight: Number(tx.block_number ?? tx.blockNumber), - blockHash: tx.block_hash ?? tx.blockHash, - blockTime: new Date(tx.block_timestamp ?? tx.blockTimestamp), - blockTimeNormalized: new Date(tx.block_timestamp ?? tx.blockTimestamp), - value: tx.value, - gasLimit: tx.gas ?? 0, - gasPrice: tx.gas_price ?? tx.gasPrice ?? 0, - fee: Number(tx.receipt_gas_used ?? tx.receiptGasUsed ?? 0) * Number(tx.gas_price ?? tx.gasPrice ?? 0), - nonce: tx.nonce, - to: Web3.utils.toChecksumAddress(tx.to_address ?? tx.toAddress), - from: Web3.utils.toChecksumAddress(tx.from_address ?? tx.fromAddress), - data: tx.input, - internal: [], - calls: tx?.internal_transactions?.map(t => this._transformInternalTransaction(t)) || [], - effects: [], - category: tx.category, - wallets: [], - transactionIndex: tx.transaction_index ?? tx.transactionIndex - } as IEVMTransactionTransformed; - EVMTransactionStorage.addEffectsToTxs([transformed]); - return transformed; - } catch (e: any) { - logger.error('Error transforming transaction from Moralis: %o -- %o', txid || tx, e.stack || e.message || e); - throw e; - } - } - - private _transformInternalTransaction(tx) { - return { - from: Web3.utils.toChecksumAddress(tx.from), - to: Web3.utils.toChecksumAddress(tx.to), - gas: tx.gas, - gasUsed: tx.gas_used, - input: tx.input, - output: tx.output, - type: tx.type, - value: tx.value, - abiType: EVMTransactionStorage.abiDecode(tx.input) - } as GethTraceCall; - } - - private _transformTokenTransfer(transfer) { - const _transfer = this._transformTransaction(transfer); - return { - ..._transfer, - transactionHash: transfer.transaction_hash, - transactionIndex: transfer.transaction_index, - contractAddress: transfer.contract_address ?? transfer.address, - name: transfer.token_name - } as Partial | any; - } - - private _transformQueryParams(params) { - const { chainId, args } = params; - const query = { - chain: this._formatChainId(chainId), - } as any; - if (args) { - if (args.startBlock || args.endBlock) { - if (args.startBlock) { - query.from_block = Number(args.startBlock); - } - if (args.endBlock) { - query.to_block = Number(args.endBlock); - } - } else { - if (args.startDate) { - query.from_date = args.startDate; - } - if (args.endDate) { - query.to_date = args.endDate; - } - } - if (args.direction) { - query.order = Number(args.direction) > 0 ? 'ASC' : 'DESC'; - } - if (args.date) { - query.date = new Date(args.date).getTime(); - } - } - return query; - } - private _calculateConfirmations(tx, tip) { let confirmations = 0; if (tx.blockHeight && tx.blockHeight >= 0) { @@ -453,31 +369,6 @@ export class MoralisStateProvider extends BaseEVMStateProvider { return confirmations; } - private _buildQueryString(params: Record): string { - const query: string[] = []; - - if (params.chain) { - params.chain = this._formatChainId(params.chain); - } - - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) { - // add array values in the form of key[i]=value - if (value[i] != null) query.push(`${key}%5B${i}%5D=${value[i]}`); - } - } else if (value != null) { - query.push(`${key}=${value}`); - } - } - - return query.length ? `?${query.join('&')}` : ''; - } - - private _formatChainId(chainId) { - return '0x' + parseInt(chainId).toString(16); - } - /** * Request wrapper for moralis Streams (subscriptions) * @param method @@ -509,7 +400,7 @@ export class MoralisStateProvider extends BaseEVMStateProvider { async createAddressSubscription(params: ChainNetwork & ChainId) { const { chain, network, chainId } = params; - const _chainId = this._formatChainId(chainId); + const _chainId = formatMoralisChainId(chainId); const result: any = await this._subsRequest('PUT', this.baseStreamUrl, { description: `Bitcore ${_chainId} - ${os.hostname()} - addresses`, diff --git a/packages/bitcore-node/src/providers/chain-state/external/adapters/factory.ts b/packages/bitcore-node/src/providers/chain-state/external/adapters/factory.ts index 312ce2cb16b..0d6c817e104 100644 --- a/packages/bitcore-node/src/providers/chain-state/external/adapters/factory.ts +++ b/packages/bitcore-node/src/providers/chain-state/external/adapters/factory.ts @@ -1,10 +1,12 @@ import { AlchemyAdapter } from './alchemy'; +import { MoralisAdapter } from './moralis'; import type { IIndexedAPIAdapter } from './IIndexedAPIAdapter'; import type { IMultiProviderConfig } from '../../../../types/Config'; export class AdapterFactory { private static registry: Record IIndexedAPIAdapter> = { - alchemy: AlchemyAdapter + alchemy: AlchemyAdapter, + moralis: MoralisAdapter }; static createAdapter(providerConfig: IMultiProviderConfig): IIndexedAPIAdapter { diff --git a/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis-utils.ts b/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis-utils.ts new file mode 100644 index 00000000000..32ec2f438cb --- /dev/null +++ b/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis-utils.ts @@ -0,0 +1,105 @@ +import { Web3 } from '@bitpay-labs/crypto-wallet-core'; +import { EVMTransactionStorage } from '../../evm/models/transaction'; +import type { IEVMTransactionTransformed } from '../../evm/types'; + +/** + * Shared Moralis transformation and query-building utilities. + * Used by both MoralisAdapter (multi-provider) and MoralisStateProvider (standalone CSP). + */ + +export function transformMoralisTransaction(tx: any): IEVMTransactionTransformed { + const txid = tx.hash || tx.transaction_hash; + const transformed = { + chain: tx.chain, + network: tx.network, + txid, + blockHeight: Number(tx.block_number ?? tx.blockNumber), + blockHash: tx.block_hash ?? tx.blockHash, + blockTime: new Date(tx.block_timestamp ?? tx.blockTimestamp), + blockTimeNormalized: new Date(tx.block_timestamp ?? tx.blockTimestamp), + value: tx.value, + gasLimit: tx.gas ?? 0, + gasPrice: tx.gas_price ?? tx.gasPrice ?? 0, + fee: Number(tx.receipt_gas_used ?? tx.receiptGasUsed ?? 0) * Number(tx.gas_price ?? tx.gasPrice ?? 0), + nonce: tx.nonce, + to: (tx.to_address ?? tx.toAddress) + ? Web3.utils.toChecksumAddress(tx.to_address ?? tx.toAddress) + : '', + from: Web3.utils.toChecksumAddress(tx.from_address ?? tx.fromAddress), + data: tx.input, + internal: [], + calls: tx?.internal_transactions?.map((t: any) => transformMoralisInternalTx(t)) || [], + effects: [], + category: tx.category, + wallets: [], + transactionIndex: tx.transaction_index ?? tx.transactionIndex + } as IEVMTransactionTransformed; + EVMTransactionStorage.addEffectsToTxs([transformed]); + return transformed; +} + +export function transformMoralisInternalTx(tx: any) { + return { + from: Web3.utils.toChecksumAddress(tx.from), + to: Web3.utils.toChecksumAddress(tx.to), + gas: tx.gas, + gasUsed: tx.gas_used, + input: tx.input, + output: tx.output, + type: tx.type, + value: tx.value, + abiType: EVMTransactionStorage.abiDecode(tx.input) + }; +} + +export function transformMoralisTokenTransfer(transfer: any) { + const base = transformMoralisTransaction(transfer); + return { + ...base, + transactionHash: transfer.transaction_hash, + transactionIndex: transfer.transaction_index, + contractAddress: transfer.contract_address ?? transfer.address, + name: transfer.token_name + }; +} + +export function transformMoralisQueryParams(params: { chainId: string | bigint; args: any }) { + const { chainId, args } = params; + const query: any = { chain: formatMoralisChainId(chainId) }; + if (args) { + if (args.startBlock || args.endBlock) { + if (args.startBlock) query.from_block = Number(args.startBlock); + if (args.endBlock) query.to_block = Number(args.endBlock); + } else { + if (args.startDate) query.from_date = args.startDate; + if (args.endDate) query.to_date = args.endDate; + } + if (args.direction) { + query.order = Number(args.direction) > 0 ? 'ASC' : 'DESC'; + } + if (args.date) { + query.date = new Date(args.date).getTime(); + } + } + return query; +} + +export function buildMoralisQueryString(params: Record): string { + const query: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (value[i] != null) query.push(`${key}%5B${i}%5D=${value[i]}`); + } + } else if (value != null) { + query.push(`${key}=${value}`); + } + } + return query.length ? `?${query.join('&')}` : ''; +} + +export function formatMoralisChainId(chainId: string | bigint): string { + const str = String(chainId); + if (str.startsWith('0x')) return str; + return '0x' + BigInt(str).toString(16); +} diff --git a/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis.ts b/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis.ts new file mode 100644 index 00000000000..fc2918c1f9e --- /dev/null +++ b/packages/bitcore-node/src/providers/chain-state/external/adapters/moralis.ts @@ -0,0 +1,172 @@ +import axios from 'axios'; +import config from '../../../../config'; +import { EVMTransactionStorage } from '../../evm/models/transaction'; +import { ExternalApiStream } from '../streams/apiStream'; +import { + type AdapterBlockByDateParams, + type AdapterStreamParams, + type AdapterTransactionParams, + type IIndexedAPIAdapter +} from './IIndexedAPIAdapter'; +import { AdapterError, AdapterErrorCode } from './errors'; +import { + buildMoralisQueryString, + formatMoralisChainId, + transformMoralisQueryParams, + transformMoralisTokenTransfer, + transformMoralisTransaction +} from './moralis-utils'; +import type { IMultiProviderConfig } from '../../../../types/Config'; +import type { IEVMTransactionTransformed } from '../../evm/types'; +import type { AxiosError } from 'axios'; + +const TX_HASH_REGEX = /^0x[0-9a-fA-F]{64}$/; + +export class MoralisAdapter implements IIndexedAPIAdapter { + readonly name = 'Moralis'; + + private apiKey: string; + private baseUrl = 'https://deep-index.moralis.io/api/v2.2'; + private headers: Record; + private requestTimeout: number; + + constructor(providerConfig: IMultiProviderConfig) { + const apiKey = config.externalProviders?.moralis?.apiKey; + if (!apiKey) throw new Error('MoralisAdapter: apiKey is required in config.externalProviders.moralis'); + this.apiKey = apiKey; + this.requestTimeout = providerConfig.requestTimeout ?? 30000; + this.headers = { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey + }; + } + + async getTransaction(params: AdapterTransactionParams): Promise { + const { chain, network, chainId, txId } = params; + + if (!TX_HASH_REGEX.test(txId)) { + throw new AdapterError(this.name, AdapterErrorCode.INVALID_REQUEST, `invalid txId format: ${txId}`); + } + + const query = buildMoralisQueryString({ + chain: formatMoralisChainId(chainId), + include: 'internal_transactions' + }); + + try { + const response = await axios.get( + `${this.baseUrl}/transaction/${txId}${query}`, + { headers: this.headers, timeout: this.requestTimeout } + ); + if (!response.data) return undefined; + return transformMoralisTransaction({ chain, network, ...response.data }); + } catch (error) { + if (error instanceof AdapterError) throw error; + if (axios.isAxiosError(error) && (error as AxiosError).response?.status === 404) { + return undefined; + } + this._classifyAndThrow(error); + } + } + + streamAddressTransactions(params: AdapterStreamParams): ExternalApiStream { + const { chainId, chain, network, address, args } = params; + const query = transformMoralisQueryParams({ chainId, args }); + const queryStr = buildMoralisQueryString({ + ...query, + order: (args as any).order ?? query.order ?? 'DESC', + limit: args.pageSize || 10, + include: 'internal_transactions' + }); + + const streamArgs = { + ...args, + transform: (tx: any) => { + const _tx: any = transformMoralisTransaction({ chain, network, ...tx }); + const confirmations = args.tipHeight && Number.isFinite(_tx.blockHeight) ? args.tipHeight - _tx.blockHeight + 1 : 0; + return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }); + } + }; + + return new ExternalApiStream( + `${this.baseUrl}/${address}${queryStr}`, + this.headers, + streamArgs + ); + } + + streamERC20Transfers(params: AdapterStreamParams & { tokenAddress: string }): ExternalApiStream { + const { chainId, chain, network, address, tokenAddress, args } = params; + const query = transformMoralisQueryParams({ chainId, args }); + const queryStr = buildMoralisQueryString({ + ...query, + order: (args as any).order ?? query.order ?? 'DESC', + limit: args.pageSize || 10, + contract_addresses: [tokenAddress] + }); + + const streamArgs = { + ...args, + transform: (tx: any) => { + const _tx: any = transformMoralisTokenTransfer({ chain, network, ...tx }); + const confirmations = args.tipHeight && Number.isFinite(_tx.blockHeight) ? args.tipHeight - _tx.blockHeight + 1 : 0; + return EVMTransactionStorage._apiTransform({ ..._tx, confirmations }, { object: true }); + } + }; + + return new ExternalApiStream( + `${this.baseUrl}/${address}/erc20/transfers${queryStr}`, + this.headers, + streamArgs + ); + } + + async getBlockNumberByDate(params: AdapterBlockByDateParams): Promise { + const { chainId, date } = params; + const queryStr = buildMoralisQueryString({ + chain: formatMoralisChainId(chainId), + date: new Date(date).getTime() + }); + + try { + const response = await axios.get( + `${this.baseUrl}/dateToBlock${queryStr}`, + { headers: this.headers, timeout: this.requestTimeout } + ); + return response.data.block as number; + } catch (error) { + this._classifyAndThrow(error); + } + } + + async healthCheck(): Promise { + try { + await axios.get(`${this.baseUrl}/web3/version`, { + headers: this.headers, + timeout: 5000 + }); + return true; + } catch { + return false; + } + } + + private _classifyAndThrow(error: unknown): never { + if (axios.isAxiosError(error)) { + const status = (error as AxiosError).response?.status; + if (status === 401 || status === 403) { + throw new AdapterError(this.name, AdapterErrorCode.AUTH); + } + if (status === 429) { + throw new AdapterError(this.name, AdapterErrorCode.RATE_LIMIT); + } + if ((error as any).code === 'ECONNABORTED') { + throw new AdapterError(this.name, AdapterErrorCode.TIMEOUT, `timed out after ${this.requestTimeout}ms`); + } + if (status && status >= 500) { + throw new AdapterError(this.name, AdapterErrorCode.UPSTREAM, `HTTP ${status}`); + } + } + throw new AdapterError(this.name, AdapterErrorCode.UPSTREAM, (error as Error)?.message); + } +} diff --git a/packages/bitcore-node/src/routes/api/address.ts b/packages/bitcore-node/src/routes/api/address.ts index e01928a1092..15649e64c2e 100644 --- a/packages/bitcore-node/src/routes/api/address.ts +++ b/packages/bitcore-node/src/routes/api/address.ts @@ -1,6 +1,7 @@ import express, { Request } from 'express'; import logger from '../../logger'; import { ChainStateProvider } from '../../providers/chain-state'; +import { AdapterError, AdapterErrorCode, AllProvidersUnavailableError } from '../../providers/chain-state/external/adapters/errors'; import { StreamAddressUtxosParams } from '../../types/namespaces/ChainStateProvider'; const router = express.Router({ mergeParams: true }); @@ -20,6 +21,12 @@ async function streamCoins(req: Request, res) { await ChainStateProvider.streamAddressTransactions(payload); } catch (err: any) { logger.error('Error streaming coins: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } } @@ -40,6 +47,12 @@ router.get('/:address/balance', async function (req: Request, res) { return res.send(result || { confirmed: 0, unconfirmed: 0, balance: 0 }); } catch (err: any) { logger.error('Error getting address balance: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } }); diff --git a/packages/bitcore-node/src/routes/api/tx.ts b/packages/bitcore-node/src/routes/api/tx.ts index 448e01ce601..fd1cc1cbf13 100644 --- a/packages/bitcore-node/src/routes/api/tx.ts +++ b/packages/bitcore-node/src/routes/api/tx.ts @@ -3,6 +3,7 @@ import logger from '../../logger'; import { ICoin } from '../../models/coin'; import { ITransaction } from '../../models/transaction'; import { ChainStateProvider } from '../../providers/chain-state'; +import { AdapterError, AdapterErrorCode, AllProvidersUnavailableError } from '../../providers/chain-state/external/adapters/errors'; import { StreamTransactionsParams } from '../../types/namespaces/ChainStateProvider'; import { SetCache } from '../middleware'; import { CacheTimes } from '../middleware'; @@ -38,6 +39,12 @@ router.get('/', async function(req: Request, res: Response) { return await ChainStateProvider.streamTransactions(payload); } catch (err: any) { logger.error('Error streaming wallet utxos: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } }); @@ -67,6 +74,12 @@ router.get('/:txId', async (req: Request, res: Response) => { } } catch (err: any) { logger.error('Error getting transaction: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } }); @@ -101,6 +114,12 @@ router.get('/:txId/populated', async (req: Request, res: Response) => { } } catch (err: any) { logger.error('Error getting populated transaction: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } }); @@ -123,6 +142,12 @@ router.get('/:txId/authhead', async (req: Request, res: Response) => { } } catch (err: any) { logger.error('Error getting transaction authhead: %o', err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message || err); } }); @@ -164,6 +189,12 @@ router.post('/send', async function(req: Request, res: Response) { return res.send({ txid }); } catch (err: any) { logger.error('Broadcast error: %o %o %o %o', chain, network, rawTx, err.stack || err.message || err); + if (err instanceof AllProvidersUnavailableError) { + return res.status(503).json({ error: 'All indexed API providers unavailable', message: err.message }); + } + if (err instanceof AdapterError && err.code === AdapterErrorCode.INVALID_REQUEST) { + return res.status(400).json({ error: 'Invalid request', message: err.message }); + } return res.status(500).send(err.message); } }); diff --git a/packages/bitcore-node/src/utils/redactUrl.ts b/packages/bitcore-node/src/utils/redactUrl.ts new file mode 100644 index 00000000000..b8e46a48184 --- /dev/null +++ b/packages/bitcore-node/src/utils/redactUrl.ts @@ -0,0 +1,5 @@ +export function redactUrl(url: string): string { + return url + .replace(/\/(v[23])\/[a-zA-Z0-9_-]+/g, '/$1/***REDACTED***') + .replace(/([?&])(apikey|api_key|key)=[^&]+/gi, '$1$2=***REDACTED***'); +} diff --git a/packages/bitcore-node/test/integration/multiProvider/csp.test.ts b/packages/bitcore-node/test/integration/multiProvider/csp.test.ts new file mode 100644 index 00000000000..39e72c96288 --- /dev/null +++ b/packages/bitcore-node/test/integration/multiProvider/csp.test.ts @@ -0,0 +1,115 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { MoralisAdapter } from '../../../src/providers/chain-state/external/adapters/moralis'; +import { AlchemyAdapter } from '../../../src/providers/chain-state/external/adapters/alchemy'; +import { AdapterError, AdapterErrorCode } from '../../../src/providers/chain-state/external/adapters/errors'; +import { EVMTransactionStorage } from '../../../src/providers/chain-state/evm/models/transaction'; +import config from '../../../src/config'; + +const MORALIS_KEY = (process as NodeJS.Process).env.MORALIS_API_KEY; +const ALCHEMY_KEY = (process as NodeJS.Process).env.ALCHEMY_API_KEY; + +// Known BASE mainnet transaction for cross-adapter verification +const KNOWN_TX_HASH = '0x6a4be6adf22988c7f4e92cb829b1d0e4e9a7f5bf3e2d456d63e51c9b287f1f4c'; +const CHAIN_ID = '0x2105'; // BASE mainnet = 8453 + +describe('Multi-Provider Integration (BASE mainnet)', function () { + this.timeout(30000); + + let sandbox: sinon.SinonSandbox; + let moralis: MoralisAdapter; + let alchemy: AlchemyAdapter; + const savedExternalProviders = config.externalProviders; + + before(function () { + if (!MORALIS_KEY || !ALCHEMY_KEY) { + this.skip(); + } + (config as any).externalProviders = { + ...savedExternalProviders, + moralis: { apiKey: MORALIS_KEY }, + alchemy: { apiKey: ALCHEMY_KEY } + }; + }); + + after(function () { + (config as any).externalProviders = savedExternalProviders; + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(EVMTransactionStorage, 'addEffectsToTxs').callsFake(() => {}); + sandbox.stub(EVMTransactionStorage, 'abiDecode').returns(undefined as any); + sandbox.stub(EVMTransactionStorage, '_apiTransform').callsFake((tx: any) => tx); + moralis = new MoralisAdapter({ name: 'moralis', priority: 1 }); + alchemy = new AlchemyAdapter({ name: 'alchemy', priority: 2 }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should get a known transaction via Moralis adapter', async function () { + const tx = await moralis.getTransaction({ + chain: 'BASE', + network: 'mainnet', + chainId: CHAIN_ID, + txId: KNOWN_TX_HASH + }); + expect(tx).to.exist; + expect(tx!.txid).to.equal(KNOWN_TX_HASH); + expect(tx!.blockHeight).to.be.a('number').and.to.be.greaterThan(0); + }); + + it('should get the same transaction via Alchemy adapter', async function () { + const tx = await alchemy.getTransaction({ + chain: 'BASE', + network: 'mainnet', + chainId: CHAIN_ID, + txId: KNOWN_TX_HASH + }); + expect(tx).to.exist; + expect(tx!.txid).to.equal(KNOWN_TX_HASH); + expect(tx!.blockHeight).to.be.a('number').and.to.be.greaterThan(0); + }); + + it('should stream address transactions', function (done) { + const stream = moralis.streamAddressTransactions({ + chain: 'BASE', + network: 'mainnet', + chainId: CHAIN_ID, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', // vitalik.eth + args: { pageSize: 3 } as any + }); + const results: any[] = []; + stream.on('data', (d: any) => results.push(d)); + stream.on('end', () => { + expect(results.length).to.be.greaterThan(0); + done(); + }); + stream.on('error', done); + }); + + it('should failover when primary API key is invalid', async function () { + const badConfig = config.externalProviders; + (config as any).externalProviders = { + ...badConfig, + moralis: { apiKey: 'invalid-key-12345' } + }; + const badAdapter = new MoralisAdapter({ name: 'moralis', priority: 1 }); + (config as any).externalProviders = badConfig; + + try { + await badAdapter.getTransaction({ + chain: 'BASE', + network: 'mainnet', + chainId: CHAIN_ID, + txId: KNOWN_TX_HASH + }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.be.instanceOf(AdapterError); + expect(err.code).to.equal(AdapterErrorCode.AUTH); + } + }); +}); diff --git a/packages/bitcore-node/test/integration/routes/tx.test.ts b/packages/bitcore-node/test/integration/routes/tx.test.ts index 87f7f2361f0..9c8308fa7c7 100644 --- a/packages/bitcore-node/test/integration/routes/tx.test.ts +++ b/packages/bitcore-node/test/integration/routes/tx.test.ts @@ -1,5 +1,6 @@ import sinon from 'sinon'; import supertest from 'supertest'; +import express from 'express'; import app from '../../../src/routes'; import { describe } from 'mocha'; import { intAfterHelper, intBeforeHelper } from '../../helpers/integration'; @@ -8,6 +9,9 @@ import { expect } from 'chai'; import { ITransaction, TransactionStorage } from '../../../src/models/transaction'; import { CoinStorage, ICoin } from '../../../src/models/coin'; import { MoralisStateProvider } from '../../../src/modules/moralis/api/csp'; +import { AdapterError, AdapterErrorCode, AllProvidersUnavailableError } from '../../../src/providers/chain-state/external/adapters/errors'; +import { ChainStateProvider } from '../../../src/providers/chain-state'; +import { txRoute } from '../../../src/routes/api/tx'; describe('Tx Routes', function() { @@ -462,6 +466,61 @@ describe('Tx Routes', function() { }); }); + describe('error mapping', function() { + let errorApp: express.Express; + + beforeEach(function () { + errorApp = express(); + errorApp.use('/:chain/:network/tx', txRoute.router); + }); + + describe('GET /:txId', function () { + it('should return 503 for AllProvidersUnavailableError', async function () { + sandbox.stub(ChainStateProvider, 'getTransaction').rejects( + new AllProvidersUnavailableError('getTransaction', 'ETH', 'mainnet') + ); + const res = await supertest(errorApp).get('/ETH/mainnet/tx/' + '0x' + 'a'.repeat(64)); + expect(res.status).to.equal(503); + expect(res.body.error).to.equal('All indexed API providers unavailable'); + }); + + it('should return 400 for INVALID_REQUEST AdapterError', async function () { + sandbox.stub(ChainStateProvider, 'getTransaction').rejects( + new AdapterError('Alchemy', AdapterErrorCode.INVALID_REQUEST, 'bad txId') + ); + const res = await supertest(errorApp).get('/ETH/mainnet/tx/' + '0x' + 'a'.repeat(64)); + expect(res.status).to.equal(400); + expect(res.body.error).to.equal('Invalid request'); + }); + + it('should still return 500 for generic errors', async function () { + sandbox.stub(ChainStateProvider, 'getTransaction').rejects(new Error('something broke')); + const res = await supertest(errorApp).get('/ETH/mainnet/tx/' + '0x' + 'a'.repeat(64)); + expect(res.status).to.equal(500); + }); + }); + + describe('GET / (streamTransactions)', function () { + it('should return 503 for AllProvidersUnavailableError', async function () { + sandbox.stub(ChainStateProvider, 'streamTransactions').rejects( + new AllProvidersUnavailableError('streamTransactions', 'ETH', 'mainnet') + ); + const res = await supertest(errorApp).get('/ETH/mainnet/tx/?blockHash=0x' + 'a'.repeat(64)); + expect(res.status).to.equal(503); + expect(res.body.error).to.equal('All indexed API providers unavailable'); + }); + + it('should return 400 for INVALID_REQUEST AdapterError', async function () { + sandbox.stub(ChainStateProvider, 'streamTransactions').rejects( + new AdapterError('Alchemy', AdapterErrorCode.INVALID_REQUEST, 'bad block hash') + ); + const res = await supertest(errorApp).get('/ETH/mainnet/tx/?blockHash=0x' + 'a'.repeat(64)); + expect(res.status).to.equal(400); + expect(res.body.error).to.equal('Invalid request'); + }); + }); + }); + describe('EVM', function() { beforeEach(function() { sandbox.stub(MoralisStateProvider.prototype, '_getTransactionFromMoralis').resolves({ diff --git a/packages/bitcore-node/test/unit/adapters/factory.test.ts b/packages/bitcore-node/test/unit/adapters/factory.test.ts index 1351d29476a..a9e156a1d5a 100644 --- a/packages/bitcore-node/test/unit/adapters/factory.test.ts +++ b/packages/bitcore-node/test/unit/adapters/factory.test.ts @@ -27,9 +27,10 @@ describe('AdapterFactory', function() { AdapterFactory.registerAdapter('test', undefined as any); }); - it('should list supported providers', function() { + it('should list moralis and alchemy as supported providers', function() { const providers = AdapterFactory.getSupportedProviders(); expect(providers).to.include('alchemy'); + expect(providers).to.include('moralis'); }); it('should be case-insensitive for provider names', function() { diff --git a/packages/bitcore-node/test/unit/adapters/moralis.test.ts b/packages/bitcore-node/test/unit/adapters/moralis.test.ts new file mode 100644 index 00000000000..0e2a932626c --- /dev/null +++ b/packages/bitcore-node/test/unit/adapters/moralis.test.ts @@ -0,0 +1,328 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import axios from 'axios'; +import { AdapterError, AdapterErrorCode } from '../../../src/providers/chain-state/external/adapters/errors'; +import { MoralisAdapter } from '../../../src/providers/chain-state/external/adapters/moralis'; +import { + buildMoralisQueryString, + formatMoralisChainId, + transformMoralisInternalTx, + transformMoralisQueryParams, + transformMoralisTokenTransfer, + transformMoralisTransaction +} from '../../../src/providers/chain-state/external/adapters/moralis-utils'; +import { EVMTransactionStorage } from '../../../src/providers/chain-state/evm/models/transaction'; +import config from '../../../src/config'; + +const VALID_TX_HASH = '0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1'; + +const MOCK_MORALIS_TX = { + chain: 'ETH', + network: 'mainnet', + hash: VALID_TX_HASH, + block_number: '18000000', + block_hash: '0xblockhash123', + block_timestamp: '2023-09-01T12:00:00.000Z', + value: '1000000000000000000', + gas: '21000', + gas_price: '20000000000', + receipt_gas_used: '21000', + nonce: 5, + to_address: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD1E', + from_address: '0x388C818CA8B9251b393131C08a736A67ccB19297', + input: '0x', + internal_transactions: [], + category: 'token send', + transaction_index: 42 +}; + +describe('MoralisAdapter', function () { + let sandbox: sinon.SinonSandbox; + let adapter: MoralisAdapter; + let axiosGetStub: sinon.SinonStub; + const savedExternalProviders = config.externalProviders; + + before(function () { + (config as any).externalProviders = { + ...savedExternalProviders, + moralis: { apiKey: 'test-moralis-key' } + }; + }); + + after(function () { + (config as any).externalProviders = savedExternalProviders; + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(EVMTransactionStorage, 'addEffectsToTxs').callsFake(() => {}); + sandbox.stub(EVMTransactionStorage, 'abiDecode').returns(undefined as any); + sandbox.stub(EVMTransactionStorage, '_apiTransform').callsFake((tx: any) => tx); + axiosGetStub = sandbox.stub(axios, 'get'); + adapter = new MoralisAdapter({ name: 'moralis', priority: 1 }); + }); + + afterEach(function () { + sandbox.restore(); + }); + + // --- Constructor --- + describe('constructor', function () { + it('should throw if apiKey missing from config', function () { + const saved = config.externalProviders; + (config as any).externalProviders = { moralis: { apiKey: '' } }; + expect(() => new MoralisAdapter({ name: 'moralis', priority: 1 })) + .to.throw('apiKey is required'); + (config as any).externalProviders = saved; + }); + + it('should set the adapter name', function () { + expect(adapter.name).to.equal('Moralis'); + }); + }); + + // --- getTransaction --- + describe('getTransaction', function () { + const params = { chain: 'ETH', network: 'mainnet', chainId: '1', txId: VALID_TX_HASH }; + + it('should fetch and transform a transaction', async function () { + axiosGetStub.resolves({ data: MOCK_MORALIS_TX }); + const result = await adapter.getTransaction(params); + expect(result).to.exist; + expect(result!.txid).to.equal(VALID_TX_HASH); + expect(result!.blockHeight).to.equal(18000000); + }); + + it('should return undefined on 404', async function () { + axiosGetStub.rejects({ isAxiosError: true, response: { status: 404 } }); + expect(await adapter.getTransaction(params)).to.be.undefined; + }); + + it('should return undefined when response data is empty', async function () { + axiosGetStub.resolves({ data: null }); + expect(await adapter.getTransaction(params)).to.be.undefined; + }); + + it('should throw INVALID_REQUEST for bad txId format', async function () { + try { + await adapter.getTransaction({ ...params, txId: 'not-a-hash' }); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.be.instanceOf(AdapterError); + expect(err.code).to.equal(AdapterErrorCode.INVALID_REQUEST); + expect(axiosGetStub.called).to.be.false; + } + }); + }); + + // --- Error classification --- + describe('error classification', function () { + const params = { chain: 'ETH', network: 'mainnet', chainId: '1', txId: VALID_TX_HASH }; + + const errorCases: Array<{ scenario: string; error: any; expectedCode: AdapterErrorCode }> = [ + { + scenario: 'HTTP 401 → AUTH', + error: { isAxiosError: true, response: { status: 401 } }, + expectedCode: AdapterErrorCode.AUTH + }, + { + scenario: 'HTTP 403 → AUTH', + error: { isAxiosError: true, response: { status: 403 } }, + expectedCode: AdapterErrorCode.AUTH + }, + { + scenario: 'HTTP 429 → RATE_LIMIT', + error: { isAxiosError: true, response: { status: 429 } }, + expectedCode: AdapterErrorCode.RATE_LIMIT + }, + { + scenario: 'HTTP 500 → UPSTREAM', + error: { isAxiosError: true, response: { status: 500 } }, + expectedCode: AdapterErrorCode.UPSTREAM + }, + { + scenario: 'timeout → TIMEOUT', + error: { isAxiosError: true, code: 'ECONNABORTED' }, + expectedCode: AdapterErrorCode.TIMEOUT + } + ]; + + for (const { scenario, error, expectedCode } of errorCases) { + it(`should classify ${scenario}`, async function () { + axiosGetStub.rejects(error); + try { + await adapter.getTransaction(params); + expect.fail('Should have thrown'); + } catch (err: any) { + expect(err).to.be.instanceOf(AdapterError); + expect(err.code).to.equal(expectedCode); + } + }); + } + }); + + // --- healthCheck --- + describe('healthCheck', function () { + it('should return true on success', async function () { + axiosGetStub.resolves({ data: {} }); + expect(await adapter.healthCheck()).to.equal(true); + }); + + it('should return false on failure', async function () { + axiosGetStub.rejects(new Error('network error')); + expect(await adapter.healthCheck()).to.equal(false); + }); + }); + + // --- chainId in query --- + describe('chainId in requests', function () { + const params = { chain: 'ETH', network: 'mainnet', chainId: '1', txId: VALID_TX_HASH }; + + it('should pass hex chainId through', async function () { + axiosGetStub.resolves({ data: MOCK_MORALIS_TX }); + await adapter.getTransaction({ ...params, chainId: '0x1' }); + expect(axiosGetStub.firstCall.args[0]).to.include('chain=0x1'); + }); + + it('should convert decimal chainId to hex', async function () { + axiosGetStub.resolves({ data: MOCK_MORALIS_TX }); + await adapter.getTransaction({ ...params, chainId: '137' }); + expect(axiosGetStub.firstCall.args[0]).to.include('chain=0x89'); + }); + }); + + // --- Shared moralis-utils --- + describe('moralis-utils', function () { + describe('transformMoralisTransaction', function () { + it('should map snake_case Moralis fields to IEVMTransactionTransformed', function () { + const result = transformMoralisTransaction(MOCK_MORALIS_TX); + expect(result.txid).to.equal(MOCK_MORALIS_TX.hash); + expect(result.blockHeight).to.equal(18000000); + expect(result.chain).to.equal('ETH'); + expect(result.network).to.equal('mainnet'); + }); + + it('should handle camelCase field variants', function () { + const camelTx = { + ...MOCK_MORALIS_TX, + block_number: undefined, + blockNumber: '18000000', + block_timestamp: undefined, + blockTimestamp: '2023-09-01T12:00:00.000Z', + to_address: undefined, + toAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD1E', + from_address: undefined, + fromAddress: '0x388C818CA8B9251b393131C08a736A67ccB19297', + gas_price: undefined, + gasPrice: '20000000000', + receipt_gas_used: undefined, + receiptGasUsed: '21000' + }; + const result = transformMoralisTransaction(camelTx); + expect(result.blockHeight).to.equal(18000000); + }); + + it('should use transaction_hash for ERC20 transfers', function () { + const erc20 = { ...MOCK_MORALIS_TX, hash: undefined, transaction_hash: '0xerc20hash' }; + const result = transformMoralisTransaction(erc20); + expect(result.txid).to.equal('0xerc20hash'); + }); + }); + + describe('transformMoralisInternalTx', function () { + it('should map internal transaction fields', function () { + const internal = { + from: '0x388C818CA8B9251b393131C08a736A67ccB19297', + to: '0x742d35Cc6634C0532925a3b844Bc9e7595f2bD1E', + gas: '21000', + gas_used: '21000', + input: '0x', + output: '0x', + type: 'CALL', + value: '1000000000000000000' + }; + const result = transformMoralisInternalTx(internal); + expect(result.type).to.equal('CALL'); + expect(result.value).to.equal('1000000000000000000'); + }); + }); + + describe('transformMoralisTokenTransfer', function () { + it('should extend base transaction with token fields', function () { + const transfer = { + ...MOCK_MORALIS_TX, + transaction_hash: MOCK_MORALIS_TX.hash, + contract_address: '0xtokencontract', + token_name: 'USDC' + }; + const result = transformMoralisTokenTransfer(transfer); + expect(result.contractAddress).to.equal('0xtokencontract'); + expect(result.name).to.equal('USDC'); + }); + }); + + describe('formatMoralisChainId', function () { + it('should pass through hex chainId unchanged', function () { + expect(formatMoralisChainId('0x1')).to.equal('0x1'); + }); + + it('should convert decimal string to hex', function () { + expect(formatMoralisChainId('137')).to.equal('0x89'); + }); + + it('should convert bigint to hex', function () { + expect(formatMoralisChainId(BigInt(1))).to.equal('0x1'); + }); + + it('should throw for invalid chainId', function () { + expect(() => formatMoralisChainId('not-a-number')).to.throw(); + }); + }); + + describe('buildMoralisQueryString', function () { + it('should build key=value pairs', function () { + const qs = buildMoralisQueryString({ chain: '0x1', limit: 10 }); + expect(qs).to.equal('?chain=0x1&limit=10'); + }); + + it('should encode array values', function () { + const qs = buildMoralisQueryString({ contract_addresses: ['0xA', '0xB'] }); + expect(qs).to.include('contract_addresses%5B0%5D=0xA'); + expect(qs).to.include('contract_addresses%5B1%5D=0xB'); + }); + + it('should skip null/undefined values', function () { + const qs = buildMoralisQueryString({ chain: '0x1', extra: null }); + expect(qs).to.equal('?chain=0x1'); + }); + + it('should return empty string for no params', function () { + expect(buildMoralisQueryString({})).to.equal(''); + }); + }); + + describe('transformMoralisQueryParams', function () { + it('should add block range when startBlock/endBlock present', function () { + const result = transformMoralisQueryParams({ chainId: '1', args: { startBlock: 100, endBlock: 200 } }); + expect(result.from_block).to.equal(100); + expect(result.to_block).to.equal(200); + }); + + it('should add date range when no block range', function () { + const result = transformMoralisQueryParams({ chainId: '1', args: { startDate: '2023-01-01', endDate: '2023-12-31' } }); + expect(result.from_date).to.equal('2023-01-01'); + expect(result.to_date).to.equal('2023-12-31'); + }); + + it('should convert positive direction to ASC', function () { + const result = transformMoralisQueryParams({ chainId: '1', args: { direction: 1 } }); + expect(result.order).to.equal('ASC'); + }); + + it('should convert negative direction to DESC', function () { + const result = transformMoralisQueryParams({ chainId: '1', args: { direction: -1 } }); + expect(result.order).to.equal('DESC'); + }); + }); + }); +}); diff --git a/packages/bitcore-node/test/unit/utils/index.test.ts b/packages/bitcore-node/test/unit/utils/index.test.ts index 8098905fa09..68a17e9f833 100644 --- a/packages/bitcore-node/test/unit/utils/index.test.ts +++ b/packages/bitcore-node/test/unit/utils/index.test.ts @@ -1,5 +1,6 @@ import { ObjectID } from 'mongodb'; import * as utils from '../../../src/utils'; +import { redactUrl } from '../../../src/utils/redactUrl'; import { expect } from 'chai'; describe('Utils', function() { @@ -358,4 +359,44 @@ describe('Utils', function() { expect(result).to.deep.equal(expectedResult); }); }); + + describe('redactUrl', function() { + it('should redact Alchemy v2 API key from URL', function() { + const url = 'https://eth-mainnet.g.alchemy.com/v2/abc123def456'; + expect(redactUrl(url)).to.not.include('abc123def456'); + expect(redactUrl(url)).to.include('REDACTED'); + }); + + it('should redact Alchemy v3 API key and preserve version', function() { + const url = 'https://eth-mainnet.g.alchemy.com/v3/abc123def456'; + const redacted = redactUrl(url); + expect(redacted).to.not.include('abc123def456'); + expect(redacted).to.include('/v3/***REDACTED***'); + }); + + it('should redact apikey query parameter', function() { + const url = 'https://api.example.com/data?apikey=secretkey123&chain=eth'; + expect(redactUrl(url)).to.not.include('secretkey123'); + expect(redactUrl(url)).to.include('chain=eth'); + }); + + it('should redact api_key query parameter', function() { + const url = 'https://api.example.com/data?api_key=secretkey123'; + expect(redactUrl(url)).to.not.include('secretkey123'); + }); + + it('should redact key query parameter', function() { + const url = 'https://api.example.com/data?key=secretkey123'; + expect(redactUrl(url)).to.not.include('secretkey123'); + }); + + it('should return URL unchanged if no keys present', function() { + const url = 'https://api.example.com/data?chain=eth'; + expect(redactUrl(url)).to.eq(url); + }); + + it('should handle empty string', function() { + expect(redactUrl('')).to.eq(''); + }); + }); }); \ No newline at end of file