diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index 4b07ae85b9e..ebd6e2fe16f 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -22,6 +22,7 @@ import { createSlotEpochCalc } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; +import { extractCredentials, minimizeCredentialSet } from '../credentialUtils'; import omit from 'lodash/omit.js'; import uniq from 'lodash/uniq.js'; import type { Cache } from '@cardano-sdk/util'; @@ -34,21 +35,45 @@ export const DB_MAX_SAFE_INTEGER = 2_147_483_647; type BlockfrostTx = Pick; const compareTx = (a: BlockfrostTx, b: BlockfrostTx) => a.block_height - b.block_height || a.tx_index - b.tx_index; -type BlockfrostChainHistoryProviderDependencies = { +interface BlockfrostChainHistoryProviderOptions { + queryTxsByCredentials?: boolean; +} + +interface BlockfrostChainHistoryProviderDependencies { cache: Cache; client: BlockfrostClient; networkInfoProvider: NetworkInfoProvider; logger: Logger; -}; +} export class BlockfrostChainHistoryProvider extends BlockfrostProvider implements ChainHistoryProvider { private readonly cache: Cache; private networkInfoProvider: NetworkInfoProvider; + // Feature flag to enable credential-based transaction fetching (used in transactionsByAddresses) + protected readonly queryTxsByCredentials: boolean; + + // Overload 1: Old signature (backward compatibility) + constructor(dependencies: BlockfrostChainHistoryProviderDependencies); + + // Overload 2: New signature with options + constructor(options: BlockfrostChainHistoryProviderOptions, dependencies: BlockfrostChainHistoryProviderDependencies); - constructor({ cache, client, networkInfoProvider, logger }: BlockfrostChainHistoryProviderDependencies) { - super(client, logger); - this.cache = cache; - this.networkInfoProvider = networkInfoProvider; + // Implementation signature + constructor( + optionsOrDependencies: BlockfrostChainHistoryProviderOptions | BlockfrostChainHistoryProviderDependencies, + maybeDependencies?: BlockfrostChainHistoryProviderDependencies + ) { + // Detect which overload was used + const isOldSignature = 'cache' in optionsOrDependencies; + const options = isOldSignature ? {} : (optionsOrDependencies as BlockfrostChainHistoryProviderOptions); + const dependencies = isOldSignature + ? (optionsOrDependencies as BlockfrostChainHistoryProviderDependencies) + : maybeDependencies!; + + super(dependencies.client, dependencies.logger); + this.cache = dependencies.cache; + this.networkInfoProvider = dependencies.networkInfoProvider; + this.queryTxsByCredentials = options.queryTxsByCredentials ?? false; } protected async fetchRedeemers({ @@ -501,52 +526,183 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement ); } - // eslint-disable-next-line sonarjs/cognitive-complexity + /** Processes and filters transaction results, returning deduplicated and sorted transaction IDs. */ + private processTransactionResults( + transactions: BlockfrostTransactionContent[], + blockRange: { lowerBound?: number; upperBound?: number }, + order?: 'asc' | 'desc' + ): Cardano.TransactionId[] { + const lowerBound = blockRange.lowerBound ?? 0; + const upperBound = blockRange.upperBound ?? DB_MAX_SAFE_INTEGER; + + return uniq( + transactions + .filter(({ block_height }) => block_height >= lowerBound && block_height <= upperBound) + .sort(order === 'desc' ? (a, b) => compareTx(b, a) : compareTx) + .map(({ tx_hash }) => Cardano.TransactionId(tx_hash)) + ); + } + + /** Executes the legacy per-address transaction fetching implementation. */ + private async fetchTransactionsPerAddress( + addresses: Cardano.PaymentAddress[], + pagination: TransactionsByAddressesArgs['pagination'], + blockRange: TransactionsByAddressesArgs['blockRange'] + ): Promise { + const paginationOptions = pagination + ? { + count: pagination.limit, + order: pagination.order, + page: (pagination.startAt + pagination.limit) / pagination.limit + } + : undefined; + + const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; + + const addressTransactions = await Promise.all( + addresses.map((address) => + this.fetchTransactionsByAddress(address, { + blockRange, + limit, + pagination: paginationOptions + }) + ) + ); + + return addressTransactions.flat(); + } + + /** Logs information about skipped addresses. */ + private logSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): void { + if (skippedAddresses.byron.length > 0) { + this.logger.info( + `Found ${skippedAddresses.byron.length} Byron address(es), falling back to per-address fetching` + ); + } + if (skippedAddresses.pointer.length > 0) { + this.logger.info( + `Found ${skippedAddresses.pointer.length} Pointer address(es), falling back to per-address fetching` + ); + } + } + + /** Logs minimization statistics. */ + private logMinimizationStats( + totalAddresses: number, + minimized: { paymentCredentials: Map; rewardAccounts: Map }, + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] } + ): void { + const paymentCredCount = minimized.paymentCredentials.size; + const rewardAccountCount = minimized.rewardAccounts.size; + const skippedCount = skippedAddresses.byron.length + skippedAddresses.pointer.length; + const totalQueries = paymentCredCount + rewardAccountCount + skippedCount; + + this.logger.debug( + `Minimized ${totalAddresses} address(es) to ${totalQueries} query/queries: ` + + `${paymentCredCount} payment credential(s), ${rewardAccountCount} reward account(s), ${skippedCount} skipped address(es)` + ); + } + + /** Fetches transactions for all payment credentials in parallel. */ + private async fetchAllByPaymentCredentials( + credentials: Map, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number } + ): Promise { + const results = await Promise.all( + [...credentials.keys()].map((credential) => + this.fetchTransactionsByPaymentCredential(credential, { blockRange, pagination }) + ) + ); + return results.flat(); + } + + /** Fetches transactions for all reward accounts in parallel. */ + private async fetchAllByRewardAccounts( + rewardAccounts: Map, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number } + ): Promise { + const results = await Promise.all( + [...rewardAccounts.keys()].map((rewardAccount) => + this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, pagination }) + ) + ); + return results.flat(); + } + + /** Fetches transactions for skipped addresses (Byron and Pointer). */ + private async fetchSkippedAddresses( + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] }, + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, + blockRange?: { lowerBound?: number; upperBound?: number }, + limit?: number + ): Promise { + const allSkippedAddresses = [...skippedAddresses.byron, ...skippedAddresses.pointer]; + const results = await Promise.all( + allSkippedAddresses.map((address) => this.fetchTransactionsByAddress(address, { blockRange, limit, pagination })) + ); + return results.flat(); + } + + /** Executes the optimized credential-based transaction fetching implementation. */ + private async fetchTransactionsByCredentials( + addresses: Cardano.PaymentAddress[], + pagination: TransactionsByAddressesArgs['pagination'], + blockRange: TransactionsByAddressesArgs['blockRange'] + ): Promise { + const addressGroups = extractCredentials(addresses); + + this.logSkippedAddresses(addressGroups.skippedAddresses); + + const minimized = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + this.logMinimizationStats(addresses.length, minimized, addressGroups.skippedAddresses); + + const paginationOptions = pagination + ? { + count: pagination.limit, + order: pagination.order ?? 'asc', + page: (pagination.startAt + pagination.limit) / pagination.limit + } + : undefined; + + const blockRangeOptions = { lowerBound: blockRange?.lowerBound, upperBound: blockRange?.upperBound }; + const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; + + const [paymentTxs, rewardAccountTxs, skippedAddressTxs] = await Promise.all([ + this.fetchAllByPaymentCredentials(minimized.paymentCredentials, paginationOptions, blockRangeOptions), + this.fetchAllByRewardAccounts(minimized.rewardAccounts, paginationOptions, blockRangeOptions), + this.fetchSkippedAddresses(addressGroups.skippedAddresses, paginationOptions, blockRangeOptions, limit) + ]); + + return [...paymentTxs, ...rewardAccountTxs, ...skippedAddressTxs]; + } + public async transactionsByAddresses({ addresses, pagination, blockRange }: TransactionsByAddressesArgs): Promise> { this.logger.debug(`transactionsByAddresses: ${JSON.stringify(blockRange)} ${JSON.stringify(addresses)}`); + try { - const lowerBound = blockRange?.lowerBound ?? 0; - const upperBound = blockRange?.upperBound ?? DB_MAX_SAFE_INTEGER; - const limit = pagination?.limit ?? DB_MAX_SAFE_INTEGER; - - const addressTransactions = await Promise.all( - addresses.map(async (address) => - fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>({ - haveEnoughItems: blockRange?.lowerBound - ? (transactions) => - (transactions.length > 0 && - transactions[transactions.length - 1].block_height < blockRange!.lowerBound!) || - transactions.length >= limit - : (transactions) => transactions.length >= limit, - paginationOptions: pagination - ? { - count: pagination.limit, - order: pagination.order, - page: (pagination.startAt + pagination.limit) / pagination.limit - } - : undefined, - request: (paginationQueryString) => { - let queryString = `addresses/${address}/transactions?${paginationQueryString}`; - if (blockRange?.lowerBound) queryString += `&from=${blockRange.lowerBound.toString()}`; - if (blockRange?.upperBound) queryString += `&to=${blockRange.upperBound.toString()}`; - return this.request(queryString); - } - }) - ) + const allTransactions = this.queryTxsByCredentials + ? await this.fetchTransactionsByCredentials(addresses, pagination, blockRange) + : await this.fetchTransactionsPerAddress(addresses, pagination, blockRange); + + const dedupedSortedTransactionsIds = this.processTransactionResults( + allTransactions, + { lowerBound: blockRange?.lowerBound, upperBound: blockRange?.upperBound }, + pagination?.order ); - const allTransactions = addressTransactions.flat(1); - - const dedupedSortedTransactionsIds = uniq( - allTransactions - .filter(({ block_height }) => block_height >= lowerBound && block_height <= upperBound) - .sort(pagination.order === 'desc' ? (a, b) => compareTx(b, a) : compareTx) - .map(({ tx_hash }) => Cardano.TransactionId(tx_hash)) - ); const pageResults = await this.transactionsByHashes({ ids: dedupedSortedTransactionsIds }); return { pageResults, totalResultCount: dedupedSortedTransactionsIds.length }; @@ -608,4 +764,168 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement private fetchUtxos(id: Cardano.TransactionId): Promise { return this.request(`txs/${id}/utxos`); } + + /** + * Helper to build query string with pagination and block range filters. + * + * @param baseEndpoint - The base endpoint (e.g., "addresses/xyz/transactions") + * @param paginationQueryString - Pagination query string from fetchSequentially + * @param blockRange - Optional block range filters + * @param blockRange.lowerBound - Lower bound for block range + * @param blockRange.upperBound - Upper bound for block range + * @returns Complete query string with all parameters + */ + private buildTransactionQueryString( + baseEndpoint: string, + paginationQueryString: string, + blockRange?: { lowerBound?: number; upperBound?: number } + ): string { + let queryString = `${baseEndpoint}?${paginationQueryString}`; + if (blockRange?.lowerBound) queryString += `&from=${blockRange.lowerBound.toString()}`; + if (blockRange?.upperBound) queryString += `&to=${blockRange.upperBound.toString()}`; + return queryString; + } + + /** + * Fetches transactions for a given address using the fetchSequentially pattern. + * + * @param address - The address to fetch transactions for + * @param options - Options for pagination, block range, and limit + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @param options.limit - Maximum number of transactions to fetch + * @returns Promise resolving to array of transaction contents + */ + private async fetchTransactionsByAddress( + address: Cardano.PaymentAddress, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + limit?: number; + } + ): Promise { + const limit = options.limit ?? DB_MAX_SAFE_INTEGER; + + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: options.blockRange?.lowerBound + ? (transactions) => + (transactions.length > 0 && + transactions[transactions.length - 1].block_height < options.blockRange!.lowerBound!) || + transactions.length >= limit + : (transactions) => transactions.length >= limit, + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `addresses/${address}/transactions`, + paginationQueryString, + options.blockRange + ); + return this.request(queryString); + } + } + ); + } + + /** + * Fetches transactions for a payment credential (bech32: addr_vkh or script). + * + * @param credential - Payment credential as bech32 string + * @param options - Pagination and block range options + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @returns Promise resolving to array of transaction contents + */ + protected async fetchTransactionsByPaymentCredential( + credential: Cardano.PaymentCredential, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + } + ): Promise { + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: () => false, // Fetch all pages + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `addresses/${credential}/transactions`, + paginationQueryString, + options.blockRange + ); + return this.request(queryString); + } + } + ); + } + + /** + * Fetches transactions for a reward account (stake address, bech32: stake or stake_test). + * + * @param rewardAccount - Reward account (stake address) as bech32 string + * @param options - Pagination and block range options + * @param options.pagination - Pagination options + * @param options.pagination.count - Number of results per page + * @param options.pagination.order - Sort order (asc or desc) + * @param options.pagination.page - Page number + * @param options.blockRange - Block range filters + * @param options.blockRange.lowerBound - Lower bound for block range + * @param options.blockRange.upperBound - Upper bound for block range + * @returns Promise resolving to array of transaction contents + */ + protected async fetchTransactionsByRewardAccount( + rewardAccount: Cardano.RewardAccount, + options: { + pagination?: { count: number; order?: 'asc' | 'desc'; page: number }; + blockRange?: { lowerBound?: number; upperBound?: number }; + } + ): Promise { + return fetchSequentially<{ tx_hash: string; tx_index: number; block_height: number }, BlockfrostTransactionContent>( + { + haveEnoughItems: () => false, // Fetch all pages + paginationOptions: options.pagination, + request: (paginationQueryString) => { + const queryString = this.buildTransactionQueryString( + `accounts/${rewardAccount}/transactions`, + paginationQueryString, + options.blockRange + ); + // Note: accounts/{stake_address}/transactions returns the same structure as address_transactions_content + return this.request(queryString); + } + } + ); + } + + /** + * Merges and deduplicates transaction results from multiple sources (payment credentials, reward accounts, skipped addresses). + * + * @param paymentTxs Transactions from payment credential queries + * @param rewardAccountTxs Transactions from reward account (stake address) queries + * @param skippedAddressTxs Transactions from skipped address queries (Byron, Pointer) + * @param order Sort order (asc or desc) + * @returns Sorted and deduplicated array of transaction IDs + */ + protected mergeAndDeduplicateTransactionResults( + paymentTxs: BlockfrostTransactionContent[], + rewardAccountTxs: BlockfrostTransactionContent[], + skippedAddressTxs: BlockfrostTransactionContent[], + order: 'asc' | 'desc' = 'asc' + ): Cardano.TransactionId[] { + const allTransactions = [...paymentTxs, ...rewardAccountTxs, ...skippedAddressTxs]; + + const sortedTransactions = allTransactions.sort(order === 'desc' ? (a, b) => compareTx(b, a) : compareTx); + + return uniq(sortedTransactions.map(({ tx_hash }) => Cardano.TransactionId(tx_hash))); + } } diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 76e1295a278..28e697147db 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -1,21 +1,47 @@ import { BlockfrostClient, BlockfrostProvider, BlockfrostToCore, fetchSequentially } from '../blockfrost'; -import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider } from '@cardano-sdk/core'; +import { Cardano, Serialization, UtxoByAddressesArgs, UtxoProvider, sortUtxoByTxIn } from '@cardano-sdk/core'; import { Logger } from 'ts-log'; +import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../credentialUtils'; +import uniqBy from 'lodash/uniqBy.js'; import type { Cache } from '@cardano-sdk/util'; import type { Responses } from '@blockfrost/blockfrost-js'; -type BlockfrostUtxoProviderDependencies = { +interface BlockfrostUtxoProviderOptions { + queryUtxosByCredentials?: boolean; +} + +interface BlockfrostUtxoProviderDependencies { client: BlockfrostClient; cache: Cache; logger: Logger; -}; +} export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoProvider { private readonly cache: Cache; + // Feature flag to enable credential-based UTXO fetching (used in utxoByAddresses) + protected readonly queryUtxosByCredentials: boolean; + + // Overload 1: Old signature (backward compatibility) + constructor(dependencies: BlockfrostUtxoProviderDependencies); - constructor({ cache, client, logger }: BlockfrostUtxoProviderDependencies) { - super(client, logger); - this.cache = cache; + // Overload 2: New signature with options + constructor(options: BlockfrostUtxoProviderOptions, dependencies: BlockfrostUtxoProviderDependencies); + + // Implementation signature + constructor( + optionsOrDependencies: BlockfrostUtxoProviderOptions | BlockfrostUtxoProviderDependencies, + maybeDependencies?: BlockfrostUtxoProviderDependencies + ) { + // Detect which overload was used + const isOldSignature = 'cache' in optionsOrDependencies; + const options = isOldSignature ? {} : (optionsOrDependencies as BlockfrostUtxoProviderOptions); + const dependencies = isOldSignature + ? (optionsOrDependencies as BlockfrostUtxoProviderDependencies) + : maybeDependencies!; + + super(dependencies.client, dependencies.logger); + this.cache = dependencies.cache; + this.queryUtxosByCredentials = options.queryUtxosByCredentials ?? false; } protected async fetchUtxos(addr: Cardano.PaymentAddress, paginationQueryString: string): Promise { @@ -31,6 +57,178 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr return Promise.all(utxoPromises); } + private async processUtxoContents(utxoContents: Responses['address_utxo_content']): Promise { + const utxoPromises = utxoContents.map((utxo) => + this.fetchDetailsFromCBOR(utxo.tx_hash).then((tx) => { + const txOut = tx ? tx.body.outputs.find((output) => output.address === utxo.address) : undefined; + return BlockfrostToCore.addressUtxoContent(utxo.address, utxo, txOut); + }) + ); + return Promise.all(utxoPromises); + } + + protected async fetchUtxosByPaymentCredential(credential: Cardano.PaymentCredential): Promise { + const utxoContents = await fetchSequentially< + Responses['address_utxo_content'][0], + Responses['address_utxo_content'][0] + >({ + haveEnoughItems: () => false, // Fetch all pages + request: async (paginationQueryString) => { + const queryString = `addresses/${credential}/utxos?${paginationQueryString}`; + return this.request(queryString); + } + }); + + return this.processUtxoContents(utxoContents); + } + + protected async fetchUtxosByRewardAccount( + rewardAccount: Cardano.RewardAccount, + paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean + ): Promise { + const utxoContents = await fetchSequentially< + Responses['address_utxo_content'][0], + Responses['address_utxo_content'][0] + >({ + haveEnoughItems: () => false, // Fetch all pages + request: async (paginationQueryString) => { + const queryString = `accounts/${rewardAccount}/utxos?${paginationQueryString}`; + return this.request(queryString); + } + }); + + // Filter UTXOs by payment credential before processing + const filteredUtxos = utxoContents.filter((utxo) => paymentCredentialFilter(Cardano.PaymentAddress(utxo.address))); + + // Log debug message about filtering + if (filteredUtxos.length < utxoContents.length) { + this.logger.debug( + `Filtered ${utxoContents.length - filteredUtxos.length} UTXO(s) from reward account query, kept ${ + filteredUtxos.length + }` + ); + } + + return this.processUtxoContents(filteredUtxos); + } + + protected mergeAndDeduplicateUtxos( + paymentUtxos: Cardano.Utxo[], + rewardAccountUtxos: Cardano.Utxo[], + skippedAddressUtxos: Cardano.Utxo[] + ): Cardano.Utxo[] { + const allUtxos = [...paymentUtxos, ...rewardAccountUtxos, ...skippedAddressUtxos]; + + // Deduplicate by txId + index combination + const deduplicated = uniqBy(allUtxos, (utxo: Cardano.Utxo) => `${utxo[0].txId}#${utxo[0].index}`); + + // Sort using sortUtxoByTxIn from core + return deduplicated.sort(sortUtxoByTxIn); + } + + private logSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): void { + if (skippedAddresses.byron.length > 0) { + this.logger.info( + `Found ${skippedAddresses.byron.length} Byron address(es), falling back to per-address fetching` + ); + } + if (skippedAddresses.pointer.length > 0) { + this.logger.info( + `Found ${skippedAddresses.pointer.length} Pointer address(es), falling back to per-address fetching` + ); + } + } + + private logMinimizationStats( + totalAddresses: number, + minimized: { paymentCredentials: Map; rewardAccounts: Map }, + skippedAddresses: { byron: Cardano.PaymentAddress[]; pointer: Cardano.PaymentAddress[] } + ): void { + const paymentCredCount = minimized.paymentCredentials.size; + const rewardAccountCount = minimized.rewardAccounts.size; + const skippedCount = skippedAddresses.byron.length + skippedAddresses.pointer.length; + const totalQueries = paymentCredCount + rewardAccountCount + skippedCount; + + this.logger.debug( + `Minimized ${totalAddresses} address(es) to ${totalQueries} query/queries: ` + + `${paymentCredCount} payment credential(s), ${rewardAccountCount} reward account(s), ${skippedCount} skipped address(es)` + ); + } + + private async fetchAllByPaymentCredentials( + credentials: Map + ): Promise { + const results = await Promise.all( + [...credentials.keys()].map((credential) => this.fetchUtxosByPaymentCredential(credential)) + ); + return results.flat(); + } + + private async fetchAllByRewardAccounts( + rewardAccounts: Map, + paymentCredentialFilter: (address: Cardano.PaymentAddress) => boolean + ): Promise { + const results = await Promise.all( + [...rewardAccounts.keys()].map((rewardAccount) => + this.fetchUtxosByRewardAccount(rewardAccount, paymentCredentialFilter) + ) + ); + return results.flat(); + } + + private async fetchUtxosForAddresses(addresses: Cardano.PaymentAddress[]): Promise { + const results = await Promise.all( + addresses.map((address) => + fetchSequentially({ + request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString) + }) + ) + ); + return results.flat(); + } + + private async fetchSkippedAddresses(skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }): Promise { + const allSkippedAddresses = [...skippedAddresses.byron, ...skippedAddresses.pointer]; + return this.fetchUtxosForAddresses(allSkippedAddresses); + } + + private async fetchUtxosByCredentials(addresses: Cardano.PaymentAddress[]): Promise { + const addressGroups = extractCredentials(addresses); + + this.logSkippedAddresses(addressGroups.skippedAddresses); + + const minimized = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + this.logMinimizationStats(addresses.length, minimized, addressGroups.skippedAddresses); + + const paymentCredentialFilter = createPaymentCredentialFilter(addresses); + + this.logger.debug( + `Fetching UTXOs for ${minimized.paymentCredentials.size} payment credential(s) and ${minimized.rewardAccounts.size} reward account(s)` + ); + + const [paymentUtxos, rewardAccountUtxos, skippedAddressUtxos] = await Promise.all([ + this.fetchAllByPaymentCredentials(minimized.paymentCredentials), + this.fetchAllByRewardAccounts(minimized.rewardAccounts, paymentCredentialFilter), + this.fetchSkippedAddresses(addressGroups.skippedAddresses) + ]); + + const result = this.mergeAndDeduplicateUtxos(paymentUtxos, rewardAccountUtxos, skippedAddressUtxos); + + this.logger.debug(`Merged results: ${result.length} UTXO(s)`); + + return result; + } + async fetchCBOR(hash: string): Promise { return this.request(`txs/${hash}/cbor`) .then((response) => { @@ -64,16 +262,24 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr return result; } + /** + * Retrieves UTXOs for the given addresses. + * + * Important assumption: All addresses provided must be addresses where the caller + * controls the payment credential. When queryUtxosByCredentials is enabled, this + * provider queries by reward accounts (stake addresses) and filters results to only + * include UTXOs with payment credentials extracted from the input addresses. UTXOs + * with payment credentials not present in the input will be excluded. + */ public async utxoByAddresses({ addresses }: UtxoByAddressesArgs): Promise { try { - const utxoResults = await Promise.all( - addresses.map(async (address) => - fetchSequentially({ - request: async (paginationQueryString) => await this.fetchUtxos(address, paginationQueryString) - }) - ) - ); - return utxoResults.flat(1); + // If feature flag is disabled, use original implementation + if (!this.queryUtxosByCredentials) { + return this.fetchUtxosForAddresses(addresses); + } + + // Use credential-based fetching + return await this.fetchUtxosByCredentials(addresses); } catch (error) { throw this.toProviderError(error); } diff --git a/packages/cardano-services-client/src/credentialUtils.ts b/packages/cardano-services-client/src/credentialUtils.ts new file mode 100644 index 00000000000..92ba9a43530 --- /dev/null +++ b/packages/cardano-services-client/src/credentialUtils.ts @@ -0,0 +1,336 @@ +import { Cardano } from '@cardano-sdk/core'; + +/** + * Maps of payment credentials and reward accounts (stake addresses) grouped by their addresses. + * + * Note: We use RewardAccount for stake keys because it's a stake address (credential + network ID), + * which is what we need for API queries. It's a superset of what a pure credential would be. + */ +export interface CredentialMaps { + /** Payment credentials (bech32: addr_vkh for KeyHash, script for ScriptHash) - network-agnostic */ + paymentCredentials: Map; + /** Reward accounts / stake addresses (bech32: stake/stake_test) - includes network ID */ + rewardAccounts: Map; +} + +export interface AddressGroups extends CredentialMaps { + skippedAddresses: { + byron: Cardano.PaymentAddress[]; + pointer: Cardano.PaymentAddress[]; + }; +} + +/** Parses an address, handling both bech32 and base58 formats */ +const parseAddress = (address: Cardano.PaymentAddress): Cardano.Address | null => { + try { + return Cardano.Address.fromBech32(address); + } catch { + try { + return Cardano.Address.fromBase58(address); + } catch { + return null; + } + } +}; + +/** Extracts payment credential from an address if available */ +const extractPaymentCredential = (address: Cardano.PaymentAddress): Cardano.PaymentCredential | null => { + const parsed = parseAddress(address); + if (!parsed) return null; + + // Byron addresses don't have payment credentials we can extract + if (parsed.asByron()) return null; + + // Try BaseAddress + const baseAddress = parsed.asBase(); + if (baseAddress) { + const credential = baseAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + // Try EnterpriseAddress + const enterpriseAddress = parsed.asEnterprise(); + if (enterpriseAddress) { + const credential = enterpriseAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + // Try PointerAddress + const pointerAddress = parsed.asPointer(); + if (pointerAddress) { + const credential = pointerAddress.getPaymentCredential(); + return Cardano.PaymentCredential.fromCredential(credential); + } + + return null; +}; + +/** + * Creates a filter predicate that checks if an address has a payment credential we control. + * Pure function that creates a filter based on controlled payment credentials. + * + * This filter is used to filter UTXOs returned from reward account (stake address) queries, + * ensuring we only include UTXOs where we control the payment credential. + * + * Critical assumption: All addresses provided as input are addresses where the caller + * controls the payment credential. This assumption is fundamental to the filtering logic. + * + * Performance optimization: The filter first checks for exact address matches (O(1) Set lookup) + * before attempting more expensive credential extraction and comparison. + * + * @param addresses Array of payment addresses whose payment credentials we control + * @returns Filter function that returns true if the address has a controlled payment credential + * @example + * ```typescript + * const myAddresses = ['addr1...', 'addr_test1...']; + * const filter = createPaymentCredentialFilter(myAddresses); + * + * // Filter UTXOs from a stake address query + * const filteredUtxos = allUtxos.filter(utxo => filter(utxo[0])); + * ``` + */ +export const createPaymentCredentialFilter = ( + addresses: Cardano.PaymentAddress[] +): ((address: Cardano.PaymentAddress) => boolean) => { + // Build Set of exact addresses for O(1) fast path lookup + const exactAddresses = new Set(addresses); + + // Extract all payment credentials from input addresses + const controlledCredentials = new Set(); + for (const address of addresses) { + const credential = extractPaymentCredential(address); + if (credential) { + controlledCredentials.add(credential); + } + } + + // Return filter function + return (address: Cardano.PaymentAddress): boolean => { + // Fast path: exact address match + if (exactAddresses.has(address)) { + return true; + } + + // Slow path: extract payment credential and check if controlled + const credential = extractPaymentCredential(address); + if (!credential) { + return false; + } + + return controlledCredentials.has(credential); + }; +}; + +/** Adds a credential to the map, creating the array if needed */ +const addToCredentialMap = ( + map: Map, + credential: T, + address: Cardano.PaymentAddress +) => { + if (!map.has(credential)) { + map.set(credential, []); + } + map.get(credential)!.push(address); +}; + +/** Processes a BaseAddress, extracting payment credential and reward account (stake address) */ +const processBaseAddress = ( + baseAddress: Cardano.BaseAddress, + parsedAddress: Cardano.Address, + address: Cardano.PaymentAddress, + paymentCredentials: Map, + rewardAccounts: Map +) => { + const paymentCredential = baseAddress.getPaymentCredential(); + const paymentBech32 = Cardano.PaymentCredential.fromCredential(paymentCredential); + addToCredentialMap(paymentCredentials, paymentBech32, address); + + const stakeCredential = baseAddress.getStakeCredential(); + const networkId = parsedAddress.getNetworkId(); + const rewardAccount = Cardano.RewardAccount.fromCredential(stakeCredential, networkId); + addToCredentialMap(rewardAccounts, rewardAccount, address); +}; + +/** Processes an EnterpriseAddress, extracting payment credential only */ +const processEnterpriseAddress = ( + enterpriseAddress: Cardano.EnterpriseAddress, + address: Cardano.PaymentAddress, + paymentCredentials: Map +) => { + const paymentCredential = enterpriseAddress.getPaymentCredential(); + const paymentBech32 = Cardano.PaymentCredential.fromCredential(paymentCredential); + addToCredentialMap(paymentCredentials, paymentBech32, address); +}; + +/** + * Extracts and groups payment credentials and reward accounts (stake addresses) from addresses. + * Pure function - no logging or side effects. + * + * @param addresses Array of payment addresses to extract credentials/addresses from + * @returns Grouped payment credentials, reward accounts (stake addresses), and skipped addresses + */ +export const extractCredentials = (addresses: Cardano.PaymentAddress[]): AddressGroups => { + const paymentCredentials = new Map(); + const rewardAccounts = new Map(); + const byron: Cardano.PaymentAddress[] = []; + const pointer: Cardano.PaymentAddress[] = []; + const seenAddresses = new Set(); + + for (const address of addresses) { + if (seenAddresses.has(address)) continue; + seenAddresses.add(address); + + const parsedAddress = parseAddress(address); + if (!parsedAddress) continue; + + if (parsedAddress.asByron()) { + byron.push(address); + continue; + } + + if (parsedAddress.asPointer()) { + pointer.push(address); + continue; + } + + const baseAddress = parsedAddress.asBase(); + if (baseAddress) { + processBaseAddress(baseAddress, parsedAddress, address, paymentCredentials, rewardAccounts); + continue; + } + + const enterpriseAddress = parsedAddress.asEnterprise(); + if (enterpriseAddress) { + processEnterpriseAddress(enterpriseAddress, address, paymentCredentials); + } + } + + return { + paymentCredentials, + rewardAccounts, + skippedAddresses: { + byron, + pointer + } + }; +}; + +interface CredentialInfo { + addresses: Cardano.PaymentAddress[]; + credential: Cardano.PaymentCredential | Cardano.RewardAccount; + type: 'payment' | 'stake'; + uncoveredCount: number; +} + +/** Builds a list of all payment credentials and reward accounts with their coverage info */ +const buildCredentialList = ( + paymentCredentials: Map, + rewardAccounts: Map +): CredentialInfo[] => { + const allCredentials: CredentialInfo[] = []; + + for (const [credential, addresses] of paymentCredentials.entries()) { + if (addresses.length > 0) { + allCredentials.push({ + addresses, + credential, + type: 'payment', + uncoveredCount: addresses.length + }); + } + } + + for (const [rewardAccount, addresses] of rewardAccounts.entries()) { + if (addresses.length > 0) { + allCredentials.push({ + addresses, + credential: rewardAccount, + type: 'stake', + uncoveredCount: addresses.length + }); + } + } + + return allCredentials; +}; + +/** Updates uncovered counts for all credentials */ +const updateUncoveredCounts = (credentialInfos: CredentialInfo[], coveredAddresses: Set) => { + for (const info of credentialInfos) { + info.uncoveredCount = info.addresses.filter((addr) => !coveredAddresses.has(addr)).length; + } +}; + +/** Finds the best credential to use (most coverage, prefer payment over stake on tie) */ +const findBestCredential = (credentialInfos: CredentialInfo[]): CredentialInfo | null => { + let maxInfo: CredentialInfo | null = null; + + for (const info of credentialInfos) { + if (info.uncoveredCount === 0) continue; + + if (maxInfo === null) { + maxInfo = info; + continue; + } + + const hasMoreCoverage = info.uncoveredCount > maxInfo.uncoveredCount; + const hasSameCoverageButPreferPayment = + info.uncoveredCount === maxInfo.uncoveredCount && info.type === 'payment' && maxInfo.type === 'stake'; + + if (hasMoreCoverage || hasSameCoverageButPreferPayment) { + maxInfo = info; + } + } + + return maxInfo; +}; + +/** Adds selected credential/address to result maps */ +const addToResultMaps = ( + selectedCredential: CredentialInfo, + minimizedPayment: Map, + minimizedRewardAccounts: Map +) => { + if (selectedCredential.type === 'payment') { + minimizedPayment.set(selectedCredential.credential as Cardano.PaymentCredential, selectedCredential.addresses); + } else { + minimizedRewardAccounts.set(selectedCredential.credential as Cardano.RewardAccount, selectedCredential.addresses); + } +}; + +/** + * Finds the minimum set of payment credentials and reward accounts that cover all addresses using a greedy algorithm. + * Prefers payment credentials over reward accounts (stake addresses) when coverage is equal. + * Pure function - no logging or side effects. + * + * @param credentials Payment credentials and reward accounts maps + * @returns Minimized credential maps + */ +export const minimizeCredentialSet = (credentials: CredentialMaps): CredentialMaps => { + const { paymentCredentials, rewardAccounts } = credentials; + const coveredAddresses = new Set(); + const minimizedPayment = new Map(); + const minimizedRewardAccounts = new Map(); + + const allCredentials = buildCredentialList(paymentCredentials, rewardAccounts); + + // Greedy algorithm: repeatedly pick the credential/address that covers the most uncovered addresses + while (allCredentials.some((info) => info.uncoveredCount > 0)) { + updateUncoveredCounts(allCredentials, coveredAddresses); + + const selectedCredential = findBestCredential(allCredentials); + if (!selectedCredential || selectedCredential.uncoveredCount === 0) break; + + addToResultMaps(selectedCredential, minimizedPayment, minimizedRewardAccounts); + + // Mark all addresses covered by this credential + for (const addr of selectedCredential.addresses) { + coveredAddresses.add(addr); + } + } + + return { + paymentCredentials: minimizedPayment, + rewardAccounts: minimizedRewardAccounts + }; +}; diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index 8760aeb2260..3d95ef03b5c 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -1,503 +1,367 @@ import { BlockfrostChainHistoryProvider, BlockfrostClient } from '../../src'; import { Cardano, NetworkInfoProvider } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; +import { cip19TestVectors } from '@cardano-sdk/util-dev'; import { dummyLogger as logger } from 'ts-log'; import { mockResponses } from '../util'; import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); -describe('blockfrostChainHistoryProvider', () => { - let request: jest.Mock; - let provider: BlockfrostChainHistoryProvider; - let networkInfoProvider: NetworkInfoProvider; - - const txId1 = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); - const txId2 = Cardano.TransactionId('2e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); - const address1 = Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'); - const address2 = Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - ); - const txsUtxosResponse = { - hash: txId1, - inputs: [ - { - address: - 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2', - amount: [ - { - quantity: '9732978705764', - unit: 'lovelace' - } - ], - output_index: 1, - tx_hash: '6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863' - } - ], - outputs: [ - { - address: - 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y', - amount: [ - { - quantity: '1000000000', - unit: 'lovelace' - }, - { - quantity: '63', - unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364' - }, - { - quantity: '22', - unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464' - } - ] - }, - { - address: - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x', - amount: [ - { - quantity: '9731978536963', - unit: 'lovelace' - } - ] - } - ] - }; - const mockedTx1Response = { - asset_mint_or_burn_count: 5, - block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', - block_height: 123_456, - delegation_count: 0, - fees: '182485', - hash: txId1, - index: 1, - invalid_before: null, - invalid_hereafter: '13885913', - mir_cert_count: 1, - output_amount: [ - { - quantity: '42000000', - unit: 'lovelace' - }, - { - quantity: '12', - unit: 'b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e' - } - ], - pool_retire_count: 1, - pool_update_count: 1, - redeemer_count: 1, - size: 433, - slot: 42_000_000, - stake_cert_count: 1, - utxo_count: 5, - valid_contract: true, - withdrawal_count: 1 - }; - const mockedTx2Response = { - ...mockedTx1Response, - hash: txId2 - }; - const mockedMetadataResponse = [ - { - json_metadata: { - hash: '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af', - metadata: 'https://nut.link/metadata.json' - }, - label: '1967' - }, - { - json_metadata: { - ADAUSD: [ - { - source: 'ergoOracles', - value: 3 - } - ] - }, - label: '1968' - } - ]; - const mockedMirResponse = [ - { - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - amount: '431833601', - cert_index: 0, - pot: 'reserve' - } - ]; - const mockedPoolUpdateResponse = [ - { - active_epoch: 210, - cert_index: 0, - fixed_cost: '340000000', - margin_cost: 0.05, - metadata: { - description: 'The best pool ever', - hash: '47c0c68cb57f4a5b4a87bad896fc274678e7aea98e200fa14a1cb40c0cab1d8c', - homepage: 'https://stakentus.com/', - name: 'Stake Nuts', - ticker: 'NUTS', - url: 'https://stakenuts.com/mainnet.json' - }, - owners: ['stake1u98nnlkvkk23vtvf9273uq7cph5ww6u2yq2389psuqet90sv4xv9v'], - pledge: '5000000000', - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - relays: [ - { - dns: 'relay1.stakenuts.com', - dns_srv: '_relays._tcp.relays.stakenuts.com', - ipv4: '4.4.4.4', - ipv6: 'https://stakenuts.com/mainnet.json', - port: 3001 - } - ], - reward_account: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', - vrf_key: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' - } - ]; - const mockedPoolRetireResponse = [ - { - cert_index: 0, - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - retiring_epoch: 216 - } - ]; - const mockedStakeResponse = [ - { - address: 'stake1u9t3a0tcwune5xrnfjg4q7cpvjlgx9lcv0cuqf5mhfjwrvcwrulda', - cert_index: 0, - registration: true - } - ]; - const mockedDelegationResponse = [ - { - active_epoch: 210, - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - cert_index: 0, - pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' - } - ]; - const mockedWithdrawalResponse = [ - { - address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', - amount: '431833601' - } - ]; - const mockedReedemerResponse = [ - { - fee: '172033', - purpose: 'spend', - redeemer_data_hash: '923918e403bf43c34b4ef6b48eb2ee04babed17320d8d1b9ff9ad086e86f44ec', - script_hash: 'ec26b89af41bef0f7585353831cb5da42b5b37185e0c8a526143b824', - tx_index: 0, - unit_mem: '1700', - unit_steps: '476468' +// Test constants +const MOCK_EPOCH_PARAMS = { key_deposit: '0', pool_deposit: '0' }; +const CBOR_NULL_ERROR = new Error('CBOR is null'); +const ADDR_VKH_PREFIX = 'addresses/addr_vkh'; +const TRANSACTIONS_PATH = '/transactions'; +const UTXOS_PATH = '/utxos'; +const EPOCHS_PATH = 'epochs/'; +const CBOR_PATH = '/cbor'; +const ACCOUNTS_PATH = 'accounts/'; + +// Mock endpoints that return empty arrays +const EMPTY_ENDPOINTS = [ + '/metadata', + '/mirs', + '/pool_updates', + '/pool_retires', + '/stakes', + '/delegations', + '/withdrawals', + '/redeemers' +]; + +/** Helper to create handlers for credential-based queries. */ +const createCredentialHandler = (credentialTxs: unknown[]) => (url: string) => { + if (url.includes(ADDR_VKH_PREFIX) && url.includes(TRANSACTIONS_PATH)) return Promise.resolve(credentialTxs); + return null; +}; + +/** Helper to create handlers for account-based queries. */ +const createAccountHandler = (accountTxs: unknown[]) => (url: string) => { + if (url.includes(ACCOUNTS_PATH) && url.includes(TRANSACTIONS_PATH)) return Promise.resolve(accountTxs); + return null; +}; + +/** Helper to create a common mock request handler for transaction detail tests. */ +const createMockRequestHandler = + ( + txResponses: Record, + utxosResponse: unknown, + additionalHandlers?: (url: string) => Promise | null + ) => + (url: string): Promise => { + // Handle transaction detail endpoints + for (const txId of Object.keys(txResponses)) { + if (url === `txs/${txId}`) return Promise.resolve(txResponses[txId]); } - ]; - - const mockedAddressTransactionResponse: Responses['address_transactions_content'] = [ - { - block_height: 123, - block_time: 131_322, - tx_hash: txId1, - tx_index: 0 + + // Handle empty endpoints + for (const endpoint of EMPTY_ENDPOINTS) { + if (url.includes(endpoint)) return Promise.resolve([]); } - ]; - const mockedAddress2TransactionResponse: Responses['address_transactions_content'] = [ - { - block_height: 124, - block_time: 131_322, - tx_hash: txId2, - tx_index: 0 + + // Handle standard endpoints + if (url.includes(UTXOS_PATH)) return Promise.resolve(utxosResponse); + if (url.includes(EPOCHS_PATH)) return Promise.resolve(MOCK_EPOCH_PARAMS); + if (url.includes(CBOR_PATH)) throw CBOR_NULL_ERROR; + + // Handle additional custom handlers + if (additionalHandlers) { + const result = additionalHandlers(url); + if (result !== null) return result; } - ]; - const mockedAddressTransactionDescResponse: Responses['address_transactions_content'] = []; - const mockedEpochParametersResponse = { key_deposit: '0', pool_deposit: '0' }; - const expectedHydratedTx = { - auxiliaryData: { - blob: new Map([ - [ - 1967n, - new Map([ - ['hash', '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af'], - ['metadata', 'https://nut.link/metadata.json'] - ]) - ], - [ - 1968n, - new Map([ - [ - 'ADAUSD', - [ - new Map([ - ['source', 'ergoOracles'], - ['value', 3n] - ]) - ] - ] - ]) - ] - ]) - }, - blockHeader: { - blockNo: Cardano.BlockNo(123_456), - hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), - slot: Cardano.Slot(42_000_000) - }, - body: { - certificates: [ - { - __typename: Cardano.CertificateType.PoolRetirement, - epoch: 216, - poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' - } as unknown as Cardano.HydratedCertificate, - { - __typename: 'PoolRegistrationCertificate', - deposit: 0n, - poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - poolParameters: { - cost: 340_000_000n, - id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', - margin: { - denominator: 20, - numerator: 1 - }, - owners: [], - pledge: 5_000_000_000n, - relays: [], - rewardAccount: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', - vrf: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' - } - }, - { - __typename: 'MirCertificate', - kind: 'ToStakeCreds', - pot: 'reserve', - quantity: 431_833_601n, - rewardAccount: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' - }, - { - __typename: 'RegistrationCertificate', - deposit: 0n, - stakeCredential: { - hash: '571ebd7877279a18734c91507b0164be8317f863f1c0269bba64e1b3', - type: 0 - } - } - ], - collateralReturn: undefined, - collaterals: new Array(), - fee: 182_485n, + + // Default: return empty for transaction queries + if (url.includes(TRANSACTIONS_PATH)) return Promise.resolve([]); + + return Promise.resolve([]); + }; + +describe('blockfrostChainHistoryProvider', () => { + describe('with default behavior (queryTxsByCredentials: false)', () => { + let request: jest.Mock; + let provider: BlockfrostChainHistoryProvider; + let networkInfoProvider: NetworkInfoProvider; + + const txId1 = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId2 = Cardano.TransactionId('2e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const address1 = Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'); + const address2 = Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ); + const txsUtxosResponse = { + hash: txId1, inputs: [ { - address: Cardano.PaymentAddress( - 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2' - ), - index: 1, - txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') + address: + 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2', + amount: [ + { + quantity: '9732978705764', + unit: 'lovelace' + } + ], + output_index: 1, + tx_hash: '6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863' } ], - mint: new Map([ - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] - ]), outputs: [ { - address: Cardano.PaymentAddress( - 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y' - ), - value: { - assets: new Map([ - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], - [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] - ]), - coins: 1_000_000_000n - } + address: + 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y', + amount: [ + { + quantity: '1000000000', + unit: 'lovelace' + }, + { + quantity: '63', + unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364' + }, + { + quantity: '22', + unit: '06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464' + } + ] }, { - address: Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - ), - value: { - coins: 9_731_978_536_963n - } - } - ], - proposalProcedures: undefined, - validityInterval: { - invalidBefore: undefined, - invalidHereafter: Cardano.Slot(13_885_913) - }, - votingProcedures: undefined, - withdrawals: [ - { - quantity: 431_833_601n, - stakeAddress: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + address: + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x', + amount: [ + { + quantity: '9731978536963', + unit: 'lovelace' + } + ] } ] - }, - id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), - index: 1, - inputSource: Cardano.InputSource.inputs, - txSize: 433, - witness: { - redeemers: [ + }; + const mockedTx1Response = { + asset_mint_or_burn_count: 5, + block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', + block_height: 123_456, + delegation_count: 0, + fees: '182485', + hash: txId1, + index: 1, + invalid_before: null, + invalid_hereafter: '13885913', + mir_cert_count: 1, + output_amount: [ { - data: Buffer.from(new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100])), - executionUnits: { - memory: 1700, - steps: 476_468 - }, - index: 0, - purpose: 'spend' - } as Cardano.Redeemer - ], - signatures: new Map() // not available in blockfrost - } - } as Cardano.HydratedTx; - - beforeEach(() => { - request = jest.fn(); - const client = { request } as unknown as BlockfrostClient; - networkInfoProvider = { - eraSummaries: jest.fn().mockResolvedValue([ + quantity: '42000000', + unit: 'lovelace' + }, { - end: { slot: 100, time: new Date(1_506_203_092_000) }, - parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, - start: { slot: 0, time: new Date(1_506_203_091_000) } + quantity: '12', + unit: 'b0d07d45fe9514f80213f4020e5a61241458be626841cde717cb38a76e7574636f696e' } - ]) - } as unknown as NetworkInfoProvider; - const cacheStorage = new Map(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cache: Cache = { - async get(key) { - return cacheStorage.get(key); + ], + pool_retire_count: 1, + pool_update_count: 1, + redeemer_count: 1, + size: 433, + slot: 42_000_000, + stake_cert_count: 1, + utxo_count: 5, + valid_contract: true, + withdrawal_count: 1 + }; + const mockedTx2Response = { + ...mockedTx1Response, + hash: txId2 + }; + const mockedMetadataResponse = [ + { + json_metadata: { + hash: '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af', + metadata: 'https://nut.link/metadata.json' + }, + label: '1967' }, - async set(key, value) { - cacheStorage.set(key, value); + { + json_metadata: { + ADAUSD: [ + { + source: 'ergoOracles', + value: 3 + } + ] + }, + label: '1968' } - }; - - provider = new BlockfrostChainHistoryProvider({ - cache, - client, - logger, - networkInfoProvider - }); - mockResponses(request, [ - [`txs/${txId1}/utxos`, txsUtxosResponse], - [`txs/${txId1}`, mockedTx1Response], - [`txs/${txId1}/metadata`, mockedMetadataResponse], - [`txs/${txId1}/mirs`, mockedMirResponse], - [`txs/${txId1}/pool_updates`, mockedPoolUpdateResponse], - [`txs/${txId1}/pool_retires`, mockedPoolRetireResponse], - [`txs/${txId1}/stakes`, mockedStakeResponse], - [`txs/${txId1}/delegations`, mockedDelegationResponse], - [`txs/${txId1}/withdrawals`, mockedWithdrawalResponse], - [`txs/${txId1}/redeemers`, mockedReedemerResponse], - [`txs/${txId2}/utxos`, txsUtxosResponse], - [`txs/${txId2}`, mockedTx2Response], - [`txs/${txId2}/metadata`, mockedMetadataResponse], - [`txs/${txId2}/mirs`, mockedMirResponse], - [`txs/${txId2}/pool_updates`, mockedPoolUpdateResponse], - [`txs/${txId2}/pool_retires`, mockedPoolRetireResponse], - [`txs/${txId2}/stakes`, mockedStakeResponse], - [`txs/${txId2}/delegations`, mockedDelegationResponse], - [`txs/${txId2}/withdrawals`, mockedWithdrawalResponse], - [`txs/${txId2}/redeemers`, mockedReedemerResponse], - [`addresses/${address1}/transactions?page=1&count=20`, mockedAddressTransactionResponse], - [`addresses/${address1}/transactions?page=1&count=1`, mockedAddressTransactionResponse], - [`addresses/${address2}/transactions?page=1&count=1`, mockedAddress2TransactionResponse], - [ - `addresses/${Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - )}/transactions?page=1&count=20`, - mockedAddressTransactionResponse - ], - [ - 'addresses/2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr/transactions?page=1&count=20&order=desc', - mockedAddressTransactionDescResponse - ], - ['epochs/420000/parameters', mockedEpochParametersResponse], - [`txs/${txId1}/cbor`, new Error('CBOR is null')] - ]); - }); - - describe('transactionsBy*', () => { - describe('transactionsByAddresses', () => { - test('converts responses correctly', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, startAt: 0 } - }); - - expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); - expect(response.pageResults[0]).toEqual(expectedHydratedTx); - }); - test('supports desc order', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, order: 'desc', startAt: 0 } - }); + ]; + const mockedMirResponse = [ + { + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + amount: '431833601', + cert_index: 0, + pot: 'reserve' + } + ]; + const mockedPoolUpdateResponse = [ + { + active_epoch: 210, + cert_index: 0, + fixed_cost: '340000000', + margin_cost: 0.05, + metadata: { + description: 'The best pool ever', + hash: '47c0c68cb57f4a5b4a87bad896fc274678e7aea98e200fa14a1cb40c0cab1d8c', + homepage: 'https://stakentus.com/', + name: 'Stake Nuts', + ticker: 'NUTS', + url: 'https://stakenuts.com/mainnet.json' + }, + owners: ['stake1u98nnlkvkk23vtvf9273uq7cph5ww6u2yq2389psuqet90sv4xv9v'], + pledge: '5000000000', + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + relays: [ + { + dns: 'relay1.stakenuts.com', + dns_srv: '_relays._tcp.relays.stakenuts.com', + ipv4: '4.4.4.4', + ipv6: 'https://stakenuts.com/mainnet.json', + port: 3001 + } + ], + reward_account: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', + vrf_key: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' + } + ]; + const mockedPoolRetireResponse = [ + { + cert_index: 0, + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + retiring_epoch: 216 + } + ]; + const mockedStakeResponse = [ + { + address: 'stake1u9t3a0tcwune5xrnfjg4q7cpvjlgx9lcv0cuqf5mhfjwrvcwrulda', + cert_index: 0, + registration: true + } + ]; + const mockedDelegationResponse = [ + { + active_epoch: 210, + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + cert_index: 0, + pool_id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' + } + ]; + const mockedWithdrawalResponse = [ + { + address: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc', + amount: '431833601' + } + ]; + const mockedReedemerResponse = [ + { + fee: '172033', + purpose: 'spend', + redeemer_data_hash: '923918e403bf43c34b4ef6b48eb2ee04babed17320d8d1b9ff9ad086e86f44ec', + script_hash: 'ec26b89af41bef0f7585353831cb5da42b5b37185e0c8a526143b824', + tx_index: 0, + unit_mem: '1700', + unit_steps: '476468' + } + ]; - expect(response.totalResultCount).toBe(0); - }); - test('deduplicates transactions', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [ - Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'), - Cardano.PaymentAddress( - 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' - ) + const mockedAddressTransactionResponse: Responses['address_transactions_content'] = [ + { + block_height: 123, + block_time: 131_322, + tx_hash: txId1, + tx_index: 0 + } + ]; + const mockedAddress2TransactionResponse: Responses['address_transactions_content'] = [ + { + block_height: 124, + block_time: 131_322, + tx_hash: txId2, + tx_index: 0 + } + ]; + const mockedAddressTransactionDescResponse: Responses['address_transactions_content'] = []; + const mockedEpochParametersResponse = { key_deposit: '0', pool_deposit: '0' }; + const expectedHydratedTx = { + auxiliaryData: { + blob: new Map([ + [ + 1967n, + new Map([ + ['hash', '6bf124f217d0e5a0a8adb1dbd8540e1334280d49ab861127868339f43b3948af'], + ['metadata', 'https://nut.link/metadata.json'] + ]) ], - pagination: { limit: 20, startAt: 0 } - }); - expect(response.pageResults).toHaveLength(mockedAddressTransactionResponse.length); - expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); - }); - test('returns up to the {limit*addresses.length} number of transactions', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [address1, address2], - pagination: { limit: 1, startAt: 0 } - }); - - const totalResultCount = mockedAddressTransactionResponse.length + mockedAddress2TransactionResponse.length; - expect(response.totalResultCount).toBe(totalResultCount); - expect(response.pageResults.length).toBe(totalResultCount); - }); - }); - - describe('transactionsByHashes', () => { - test('converts responses correctly', async () => { - const response = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) - }); - - expect(response).toHaveLength(1); - expect(response[0]).toEqual(expectedHydratedTx); - }); - }); - }); - describe('transactionsBy* with CBOR', () => { - const mockedCborResponse = { - cbor: '84a90082825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a900825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a9010d81825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a90112818258205de304d9c8884dd62ad8535d529e3d8fd5f212cc3c0c39410e4f3bccfca6e46b010182a300581d7039a4c3afe97b4c2d3385fefd5206d1865c74786b7ce955ebb6532e7a01821a001b9f18a1581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aa14d446a65644f7261636c654e465401028201d81858a6d8799f58404fe28ad1e94e742a6c79eb8f7cc44128965579792e4b5be940bfa61c3797914970fe2055016c2b7c3bbd9de43194e82b22a4ccdbee80b4099f73a304d9550707d8799fd8799f1a000f42401a00053dc9ffd8799fd8799fd87a9f1b00000192515efa80ffd87a80ffd8799fd87a9f1b00000192516cb620ffd87a80ffff43555344ff581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aff82583900f0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a4c0ec23eba3aa27a4b8d61107f59f3e0d24b7bb6d7ae1a4bbd689c8d1abe8373ea021a0003ff5f031a044e9894081a044e95100e81581cf0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a0b58205b3d8c3bc5032e4d2dbe3c07d23d7fb5133f4ec7466e73744317cebe2ed12610a200818258205401b7f67442e6cc870fbcffe921d24ab03e97e03bb655a24b4f424fd832c61558405bddd2f0d88e254ef1f0a84276aaadea4df3b05f8c441d9774b6a8d1c2556437a79e5131ea4475169d45e6e02bb3b31cd93216c5499e2c316aa68e485d6800000581840000d87980821a0006a7f41a0ab2d5a5f5f6' - }; - const expectedHydratedTxCBOR = { - auxiliaryData: undefined, + [ + 1968n, + new Map([ + [ + 'ADAUSD', + [ + new Map([ + ['source', 'ergoOracles'], + ['value', 3n] + ]) + ] + ] + ]) + ] + ]) + }, blockHeader: { blockNo: Cardano.BlockNo(123_456), hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), slot: Cardano.Slot(42_000_000) }, body: { - certificates: undefined, + certificates: [ + { + __typename: Cardano.CertificateType.PoolRetirement, + epoch: 216, + poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy' + } as unknown as Cardano.HydratedCertificate, + { + __typename: 'PoolRegistrationCertificate', + deposit: 0n, + poolId: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + poolParameters: { + cost: 340_000_000n, + id: 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy', + margin: { + denominator: 20, + numerator: 1 + }, + owners: [], + pledge: 5_000_000_000n, + relays: [], + rewardAccount: 'stake1uxkptsa4lkr55jleztw43t37vgdn88l6ghclfwuxld2eykgpgvg3f', + vrf: '0b5245f9934ec2151116fb8ec00f35fd00e0aa3b075c4ed12cce440f999d8233' + } + }, + { + __typename: 'MirCertificate', + kind: 'ToStakeCreds', + pot: 'reserve', + quantity: 431_833_601n, + rewardAccount: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + }, + { + __typename: 'RegistrationCertificate', + deposit: 0n, + stakeCredential: { + hash: '571ebd7877279a18734c91507b0164be8317f863f1c0269bba64e1b3', + type: 0 + } + } + ], collateralReturn: undefined, collaterals: new Array(), - fee: 261_983n, + fee: 182_485n, inputs: [ { address: Cardano.PaymentAddress( @@ -507,7 +371,10 @@ describe('blockfrostChainHistoryProvider', () => { txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') } ], - mint: undefined, + mint: new Map([ + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] + ]), outputs: [ { address: Cardano.PaymentAddress( @@ -532,11 +399,16 @@ describe('blockfrostChainHistoryProvider', () => { ], proposalProcedures: undefined, validityInterval: { - invalidBefore: Cardano.Slot(72_258_832), - invalidHereafter: Cardano.Slot(72_259_732) + invalidBefore: undefined, + invalidHereafter: Cardano.Slot(13_885_913) }, votingProcedures: undefined, - withdrawals: undefined + withdrawals: [ + { + quantity: 431_833_601n, + stakeAddress: 'stake1u9r76ypf5fskppa0cmttas05cgcswrttn6jrq4yd7jpdnvc7gt0yc' + } + ] }, id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), index: 1, @@ -549,83 +421,658 @@ describe('blockfrostChainHistoryProvider', () => { new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100]) ), executionUnits: { - memory: 436_212, - steps: 179_492_261 + memory: 1700, + steps: 476_468 }, index: 0, purpose: 'spend' - } + } as Cardano.Redeemer ], - signatures: new Map() + signatures: new Map() // not available in blockfrost } } as Cardano.HydratedTx; beforeEach(() => { - const txId = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); - const id = txId.toString(); + request = jest.fn(); + const client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostChainHistoryProvider({ + cache, + client, + logger, + networkInfoProvider + }); mockResponses(request, [ - [`txs/${id}/utxos`, txsUtxosResponse], - [`txs/${id}`, mockedTx1Response], - [`txs/${id}/metadata`, mockedMetadataResponse], - [`txs/${id}/mirs`, mockedMirResponse], - [`txs/${id}/pool_updates`, mockedPoolUpdateResponse], - [`txs/${id}/pool_retires`, mockedPoolRetireResponse], - [`txs/${id}/stakes`, mockedStakeResponse], - [`txs/${id}/delegations`, mockedDelegationResponse], - [`txs/${id}/withdrawals`, mockedWithdrawalResponse], - [`txs/${id}/redeemers`, mockedReedemerResponse], + [`txs/${txId1}/utxos`, txsUtxosResponse], + [`txs/${txId1}`, mockedTx1Response], + [`txs/${txId1}/metadata`, mockedMetadataResponse], + [`txs/${txId1}/mirs`, mockedMirResponse], + [`txs/${txId1}/pool_updates`, mockedPoolUpdateResponse], + [`txs/${txId1}/pool_retires`, mockedPoolRetireResponse], + [`txs/${txId1}/stakes`, mockedStakeResponse], + [`txs/${txId1}/delegations`, mockedDelegationResponse], + [`txs/${txId1}/withdrawals`, mockedWithdrawalResponse], + [`txs/${txId1}/redeemers`, mockedReedemerResponse], + [`txs/${txId2}/utxos`, txsUtxosResponse], + [`txs/${txId2}`, mockedTx2Response], + [`txs/${txId2}/metadata`, mockedMetadataResponse], + [`txs/${txId2}/mirs`, mockedMirResponse], + [`txs/${txId2}/pool_updates`, mockedPoolUpdateResponse], + [`txs/${txId2}/pool_retires`, mockedPoolRetireResponse], + [`txs/${txId2}/stakes`, mockedStakeResponse], + [`txs/${txId2}/delegations`, mockedDelegationResponse], + [`txs/${txId2}/withdrawals`, mockedWithdrawalResponse], + [`txs/${txId2}/redeemers`, mockedReedemerResponse], + [`addresses/${address1}/transactions?page=1&count=20`, mockedAddressTransactionResponse], + [`addresses/${address1}/transactions?page=1&count=1`, mockedAddressTransactionResponse], + [`addresses/${address2}/transactions?page=1&count=1`, mockedAddress2TransactionResponse], [ `addresses/${Cardano.PaymentAddress( - '2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr' - ).toString()}/transactions?page=1&count=20`, + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + )}/transactions?page=1&count=20`, mockedAddressTransactionResponse ], + [ + 'addresses/2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr/transactions?page=1&count=20&order=desc', + mockedAddressTransactionDescResponse + ], ['epochs/420000/parameters', mockedEpochParametersResponse], - [`txs/${id}/cbor`, mockedCborResponse] + [`txs/${txId1}/cbor`, new Error('CBOR is null')] ]); }); - describe('transactionsByAddresses (CBOR)', () => { - test('converts responses correctly (CBOR)', async () => { - const response = await provider.transactionsByAddresses({ - addresses: [Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr')], - pagination: { limit: 20, startAt: 0 } + describe('transactionsBy*', () => { + describe('transactionsByAddresses', () => { + test('converts responses correctly', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, startAt: 0 } + }); + + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); + expect(response.pageResults[0]).toEqual(expectedHydratedTx); + }); + test('supports desc order', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, order: 'desc', startAt: 0 } + }); + + expect(response.totalResultCount).toBe(0); + }); + test('deduplicates transactions', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr'), + Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ) + ], + pagination: { limit: 20, startAt: 0 } + }); + expect(response.pageResults).toHaveLength(mockedAddressTransactionResponse.length); + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); }); + test('returns up to the {limit*addresses.length} number of transactions', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [address1, address2], + pagination: { limit: 1, startAt: 0 } + }); - expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); - expect(response.pageResults[0]).toEqual(expectedHydratedTxCBOR); + const totalResultCount = mockedAddressTransactionResponse.length + mockedAddress2TransactionResponse.length; + expect(response.totalResultCount).toBe(totalResultCount); + expect(response.pageResults.length).toBe(totalResultCount); + }); + }); + + describe('transactionsByHashes', () => { + test('converts responses correctly', async () => { + const response = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + }); + + expect(response).toHaveLength(1); + expect(response[0]).toEqual(expectedHydratedTx); + }); }); }); + describe('transactionsBy* with CBOR', () => { + const mockedCborResponse = { + cbor: '84a90082825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a900825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a9010d81825820262e95982cfe9fbc565a0e9a5343d323be8e08e51de23a5262b75ce6984179a90112818258205de304d9c8884dd62ad8535d529e3d8fd5f212cc3c0c39410e4f3bccfca6e46b010182a300581d7039a4c3afe97b4c2d3385fefd5206d1865c74786b7ce955ebb6532e7a01821a001b9f18a1581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aa14d446a65644f7261636c654e465401028201d81858a6d8799f58404fe28ad1e94e742a6c79eb8f7cc44128965579792e4b5be940bfa61c3797914970fe2055016c2b7c3bbd9de43194e82b22a4ccdbee80b4099f73a304d9550707d8799fd8799f1a000f42401a00053dc9ffd8799fd8799fd87a9f1b00000192515efa80ffd87a80ffd8799fd87a9f1b00000192516cb620ffd87a80ffff43555344ff581cdab1406f1c769fbdb00514c494eed47a54c7ffc5d7aafc524cca069aff82583900f0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a4c0ec23eba3aa27a4b8d61107f59f3e0d24b7bb6d7ae1a4bbd689c8d1abe8373ea021a0003ff5f031a044e9894081a044e95100e81581cf0a26fc170ad82b64a3d43dede08e78ea6e2028b5101058d0263a80a0b58205b3d8c3bc5032e4d2dbe3c07d23d7fb5133f4ec7466e73744317cebe2ed12610a200818258205401b7f67442e6cc870fbcffe921d24ab03e97e03bb655a24b4f424fd832c61558405bddd2f0d88e254ef1f0a84276aaadea4df3b05f8c441d9774b6a8d1c2556437a79e5131ea4475169d45e6e02bb3b31cd93216c5499e2c316aa68e485d6800000581840000d87980821a0006a7f41a0ab2d5a5f5f6' + }; + const expectedHydratedTxCBOR = { + auxiliaryData: undefined, + blockHeader: { + blockNo: Cardano.BlockNo(123_456), + hash: Cardano.BlockId('356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940'), + slot: Cardano.Slot(42_000_000) + }, + body: { + certificates: undefined, + collateralReturn: undefined, + collaterals: new Array(), + fee: 261_983n, + inputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qr05llxkwg5t6c4j3ck5mqfax9wmz35rpcgw3qthrn9z7xcxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flknstdz3k2' + ), + index: 1, + txId: Cardano.TransactionId('6d50c330a6fba79de6949a8dcd5e4b7ffa3f9442f0c5bed7a78fa6d786c6c863') + } + ], + mint: undefined, + outputs: [ + { + address: Cardano.PaymentAddress( + 'addr_test1qzx9hu8j4ah3auytk0mwcupd69hpc52t0cw39a65ndrah86djs784u92a3m5w475w3w35tyd6v3qumkze80j8a6h5tuqq5xe8y' + ), + value: { + assets: new Map([ + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108617364'), 63n], + [Cardano.AssetId('06f8c5655b4e2b5911fee8ef2fc66b4ce64c8835642695c730a3d108646464'), 22n] + ]), + coins: 1_000_000_000n + } + }, + { + address: Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ), + value: { + coins: 9_731_978_536_963n + } + } + ], + proposalProcedures: undefined, + validityInterval: { + invalidBefore: Cardano.Slot(72_258_832), + invalidHereafter: Cardano.Slot(72_259_732) + }, + votingProcedures: undefined, + withdrawals: undefined + }, + id: Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'), + index: 1, + inputSource: Cardano.InputSource.inputs, + txSize: 433, + witness: { + redeemers: [ + { + data: Buffer.from( + new Uint8Array([110, 111, 116, 32, 105, 109, 112, 108, 101, 109, 101, 110, 116, 101, 100]) + ), + executionUnits: { + memory: 436_212, + steps: 179_492_261 + }, + index: 0, + purpose: 'spend' + } + ], + signatures: new Map() + } + } as Cardano.HydratedTx; + + beforeEach(() => { + const txId = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const id = txId.toString(); + mockResponses(request, [ + [`txs/${id}/utxos`, txsUtxosResponse], + [`txs/${id}`, mockedTx1Response], + [`txs/${id}/metadata`, mockedMetadataResponse], + [`txs/${id}/mirs`, mockedMirResponse], + [`txs/${id}/pool_updates`, mockedPoolUpdateResponse], + [`txs/${id}/pool_retires`, mockedPoolRetireResponse], + [`txs/${id}/stakes`, mockedStakeResponse], + [`txs/${id}/delegations`, mockedDelegationResponse], + [`txs/${id}/withdrawals`, mockedWithdrawalResponse], + [`txs/${id}/redeemers`, mockedReedemerResponse], + [ + `addresses/${Cardano.PaymentAddress( + '2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr' + ).toString()}/transactions?page=1&count=20`, + mockedAddressTransactionResponse + ], + ['epochs/420000/parameters', mockedEpochParametersResponse], + [`txs/${id}/cbor`, mockedCborResponse] + ]); + }); + + describe('transactionsByAddresses (CBOR)', () => { + test('converts responses correctly (CBOR)', async () => { + const response = await provider.transactionsByAddresses({ + addresses: [ + Cardano.PaymentAddress('2cWKMJemoBai9J7kVvRTukMmdfxtjL9z7c396rTfrrzfAZ6EeQoKLC2y1k34hswwm4SVr') + ], + pagination: { limit: 20, startAt: 0 } + }); + + expect(response.totalResultCount).toBe(mockedAddressTransactionResponse.length); + expect(response.pageResults[0]).toEqual(expectedHydratedTxCBOR); + }); + }); + + describe('transactionsByHashes (CBOR)', () => { + test('converts responses correctly (CBOR)', async () => { + const response = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + }); + + expect(response).toHaveLength(1); + expect(response[0]).toEqual(expectedHydratedTxCBOR); + }); + }); + + test('caches transactions and returns them from the cache on subsequent calls', async () => { + const firstResponse = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + }); + + expect(firstResponse).toHaveLength(1); + expect(firstResponse[0]).toEqual(expectedHydratedTxCBOR); + expect(request).toHaveBeenCalled(); - describe('transactionsByHashes (CBOR)', () => { - test('converts responses correctly (CBOR)', async () => { - const response = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'].map(Cardano.TransactionId) + request.mockClear(); + + const secondResponse = await provider.transactionsByHashes({ + ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] }); - expect(response).toHaveLength(1); - expect(response[0]).toEqual(expectedHydratedTxCBOR); + expect(secondResponse).toHaveLength(1); + expect(secondResponse[0]).toEqual(expectedHydratedTxCBOR); + expect(request).not.toHaveBeenCalled(); }); }); + }); + + describe('constructor overloads', () => { + let client: BlockfrostClient; + let cache: Cache; + let networkInfoProvider: NetworkInfoProvider; - test('caches transactions and returns them from the cache on subsequent calls', async () => { - const firstResponse = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + beforeEach(() => { + const request = jest.fn(); + client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + }); + + test('old constructor signature works (backward compatibility)', () => { + const provider = new BlockfrostChainHistoryProvider({ + cache, + client, + logger, + networkInfoProvider }); - expect(firstResponse).toHaveLength(1); - expect(firstResponse[0]).toEqual(expectedHydratedTxCBOR); - expect(request).toHaveBeenCalled(); + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); + }); - request.mockClear(); + test('new constructor signature with queryTxsByCredentials: false', () => { + const provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: false }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); + }); - const secondResponse = await provider.transactionsByHashes({ - ids: ['1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477' as Cardano.TransactionId] + test('new constructor signature with queryTxsByCredentials: true', () => { + const provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: true }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(true); + }); + + test('new constructor signature defaults to false when not specified', () => { + const provider = new BlockfrostChainHistoryProvider( + {}, + { + cache, + client, + logger, + networkInfoProvider + } + ); + + expect(provider).toBeInstanceOf(BlockfrostChainHistoryProvider); + // @ts-expect-error accessing protected property for testing + expect(provider.queryTxsByCredentials).toBe(false); + }); + }); + + describe('with feature flag ON (queryTxsByCredentials: true)', () => { + let request: jest.Mock; + let provider: BlockfrostChainHistoryProvider; + let networkInfoProvider: NetworkInfoProvider; + let cache: Cache; + + const txId1 = Cardano.TransactionId('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId2 = Cardano.TransactionId('2e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + const txId3 = Cardano.TransactionId('3e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'); + + // Use real test addresses from CIP-19 test vectors + const baseAddress1 = cip19TestVectors.basePaymentKeyStakeKey; // Base address with payment key + stake key + const baseAddress2 = cip19TestVectors.basePaymentScriptStakeKey; // Base address with script + stake key + const enterpriseAddress = cip19TestVectors.enterpriseKey; // Enterprise address (no stake) + + const mockedTx1Response = { + asset_mint_or_burn_count: 0, + block: '356b7d7dbb696ccd12775c016941057a9dc70898d87a63fc752271bb46856940', + block_height: 100, + delegation_count: 0, + fees: '182485', + hash: txId1, + index: 1, + invalid_before: null, + invalid_hereafter: '13885913', + mir_cert_count: 0, + output_amount: [{ quantity: '42000000', unit: 'lovelace' }], + pool_retire_count: 0, + pool_update_count: 0, + redeemer_count: 0, + size: 433, + slot: 42_000_000, + stake_cert_count: 0, + utxo_count: 2, + valid_contract: true, + withdrawal_count: 0 + }; + + const mockedTx2Response = { ...mockedTx1Response, block_height: 101, hash: txId2 }; + const mockedTx3Response = { ...mockedTx1Response, block_height: 102, hash: txId3 }; + + const txsUtxosResponse = { + hash: txId1, + inputs: [ + { + address: baseAddress1, + amount: [{ quantity: '50000000', unit: 'lovelace' }], + output_index: 0, + tx_hash: '0000000000000000000000000000000000000000000000000000000000000000' + } + ], + outputs: [ + { + address: baseAddress1, + amount: [{ quantity: '42000000', unit: 'lovelace' }] + } + ] + }; + + beforeEach(() => { + request = jest.fn(); + const client = { request } as unknown as BlockfrostClient; + networkInfoProvider = { + eraSummaries: jest.fn().mockResolvedValue([ + { + end: { slot: 100, time: new Date(1_506_203_092_000) }, + parameters: { epochLength: 100, safeZone: 0, slotLength: 1 }, + start: { slot: 0, time: new Date(1_506_203_091_000) } + } + ]) + } as unknown as NetworkInfoProvider; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + provider = new BlockfrostChainHistoryProvider( + { queryTxsByCredentials: true }, + { + cache, + client, + logger, + networkInfoProvider + } + ); + }); + + describe('transactionsByAddresses with credential-based queries', () => { + test('single address is minimized to payment credential (1 API call)', async () => { + const credentialTxs = [{ block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + // Verify we queried by payment credential, not by address + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by address directly + const addressCalls = request.mock.calls.filter((call: string[]) => + call[0].includes(`addresses/${baseAddress1}${TRANSACTIONS_PATH}`) + ); + expect(addressCalls.length).toBe(0); + }); + + test('multiple addresses with shared stake key use reward account query', async () => { + const rewardAccountTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response }, + txsUtxosResponse, + createAccountHandler(rewardAccountTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [baseAddress1, baseAddress2], + pagination: { limit: 20, startAt: 0 } + }); + + // Should query by reward account (stake address) + const rewardAccountCalls = request.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(TRANSACTIONS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should not query by payment credentials + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBe(0); + }); + + test('deduplicates transactions correctly', async () => { + const credentialTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 }, + { block_height: 102, block_time: 1_000_040, tx_hash: txId2, tx_index: 1 } // Duplicate txId2 + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + // Should deduplicate to 2 unique transactions (txId1, txId2) + expect(result.pageResults.length).toBe(2); + expect(result.totalResultCount).toBe(2); + }); + + test('sorts transactions in descending order', async () => { + const credentialTxs = [ + { block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_020, tx_hash: txId2, tx_index: 0 }, + { block_height: 102, block_time: 1_000_040, tx_hash: txId3, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response, [txId3]: mockedTx3Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, order: 'desc', startAt: 0 } + }); + + // Should be sorted descending (newest first) + expect(result.pageResults.length).toBe(3); + expect(result.pageResults[0].id).toBe(txId3); + expect(result.pageResults[1].id).toBe(txId2); + expect(result.pageResults[2].id).toBe(txId1); + + // Verify block heights are actually in descending order + expect(result.pageResults[0].blockHeader.blockNo).toBeGreaterThan(result.pageResults[1].blockHeader.blockNo); + expect(result.pageResults[1].blockHeader.blockNo).toBeGreaterThan(result.pageResults[2].blockHeader.blockNo); }); - expect(secondResponse).toHaveLength(1); - expect(secondResponse[0]).toEqual(expectedHydratedTxCBOR); - expect(request).not.toHaveBeenCalled(); + test('applies blockRange filters', async () => { + const credentialTxs = [ + { block_height: 50, block_time: 500_000, tx_hash: txId1, tx_index: 0 }, + { block_height: 101, block_time: 1_000_000, tx_hash: txId2, tx_index: 0 }, + { block_height: 150, block_time: 1_500_000, tx_hash: txId3, tx_index: 0 } + ]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response, [txId2]: mockedTx2Response, [txId3]: mockedTx3Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + blockRange: { lowerBound: Cardano.BlockNo(75), upperBound: Cardano.BlockNo(125) }, + pagination: { limit: 20, startAt: 0 } + }); + + // Should only include txId2 (block_height: 101, within range 75-125) + // txId1 (block_height: 50) and txId3 (block_height: 150) should be filtered out + expect(result.pageResults.length).toBe(1); + expect(result.pageResults[0].id).toBe(txId2); + expect(result.pageResults[0].blockHeader.blockNo).toBe(Cardano.BlockNo(101)); + }); + + test('handles enterprise addresses (no stake credential)', async () => { + const credentialTxs = [{ block_height: 100, block_time: 1_000_000, tx_hash: txId1, tx_index: 0 }]; + + request.mockImplementation( + createMockRequestHandler( + { [txId1]: mockedTx1Response }, + txsUtxosResponse, + createCredentialHandler(credentialTxs) + ) + ); + + await provider.transactionsByAddresses({ + addresses: [enterpriseAddress], + pagination: { limit: 20, startAt: 0 } + }); + + // Should query by payment credential (no stake account query) + const credentialCalls = request.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by stake account + const stakeCalls = request.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(TRANSACTIONS_PATH) + ); + expect(stakeCalls.length).toBe(0); + }); + + test('handles empty result sets', async () => { + request.mockImplementation(createMockRequestHandler({}, txsUtxosResponse)); + + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 20, startAt: 0 } + }); + + expect(result.pageResults.length).toBe(0); + expect(result.totalResultCount).toBe(0); + }); }); }); }); diff --git a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts index 9aead010d87..7ecad7facf2 100644 --- a/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts +++ b/packages/cardano-services-client/test/Utxo/BlockfrostUtxoProvider.test.ts @@ -1,11 +1,59 @@ import { BlockfrostClient, BlockfrostUtxoProvider } from '../../src'; import { Cardano } from '@cardano-sdk/core'; import { Responses } from '@blockfrost/blockfrost-js'; -import { logger } from '@cardano-sdk/util-dev'; +import { cip19TestVectors, logger } from '@cardano-sdk/util-dev'; import { mockResponses } from '../util'; import type { Cache } from '@cardano-sdk/util'; jest.mock('@blockfrost/blockfrost-js'); +// Test constants +const ADDR_VKH_PREFIX = 'addresses/addr_vkh'; +const SCRIPT_PREFIX = 'addresses/script'; +const ACCOUNTS_PATH = 'accounts/'; +const UTXOS_PATH = '/utxos'; +const CBOR_PATH = '/cbor'; + +const CBOR_RESPONSE = { + cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' +}; + +/** Helper to create handlers for credential-based UTXO queries. */ +const createCredentialUtxoHandler = (credentialUtxos: Responses['address_utxo_content']) => (url: string) => { + if ((url.includes(ADDR_VKH_PREFIX) || url.includes(SCRIPT_PREFIX)) && url.includes(UTXOS_PATH)) { + return Promise.resolve(credentialUtxos); + } + return null; +}; + +/** Helper to create handlers for account-based UTXO queries. */ +const createAccountUtxoHandler = (accountUtxos: Responses['address_utxo_content']) => (url: string) => { + if (url.includes(ACCOUNTS_PATH) && url.includes(UTXOS_PATH)) { + return Promise.resolve(accountUtxos); + } + return null; +}; + +/** Helper to create a common mock request handler for UTXO tests. */ +const createMockUtxoHandler = + (additionalHandlers?: (url: string) => Promise | null) => + (url: string): Promise => { + // Handle CBOR endpoints + if (url.includes(CBOR_PATH)) { + return Promise.resolve(CBOR_RESPONSE); + } + + // Handle additional custom handlers + if (additionalHandlers) { + const result = additionalHandlers(url); + if (result !== null) return result; + } + + // Default: return empty for UTXO queries + if (url.includes(UTXOS_PATH)) return Promise.resolve([]); + + return Promise.resolve([]); + }; + const generateUtxoResponseMock = (qty: number) => [...Array.from({ length: qty }).keys()].map((num) => ({ amount: [ @@ -53,76 +101,354 @@ describe('blockfrostUtxoProvider', () => { ); }); - describe('utxoByAddresses', () => { - test('used addresses', async () => { - mockResponses(request, [ - [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(100)], - [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(100)], - [`addresses/${address.toString()}/utxos?page=3&count=100`, generateUtxoResponseMock(0)] - ]); - const response = await provider.utxoByAddresses({ - addresses: [address] + describe('with default behavior (queryUtxosByCredentials: false)', () => { + describe('utxoByAddresses', () => { + test('used addresses', async () => { + mockResponses(request, [ + [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(100)], + [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(100)], + [`addresses/${address.toString()}/utxos?page=3&count=100`, generateUtxoResponseMock(0)] + ]); + const response = await provider.utxoByAddresses({ + addresses: [address] + }); + + expect(response).toBeTruthy(); + expect(response[0]).toHaveLength(2); + expect(response[0][0]).toMatchObject({ + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }); + expect(response[0][1]).toMatchObject({ + address, + value: { + assets: new Map([ + [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n] + ]), + coins: 50_928_877n + } + }); + + expect(response[1]).toHaveLength(2); + expect(response[1][0]).toMatchObject({ + index: 1, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }); + expect(response[1][1]).toMatchObject({ + address, + value: { + assets: new Map([ + [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n] + ]), + coins: 50_928_878n + } + }); }); - expect(response).toBeTruthy(); - expect(response[0]).toHaveLength(2); - expect(response[0][0]).toMatchObject({ - index: 0, - txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + test('does not call txs/${hash}/cbor when data is cached', async () => { + const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + + mockResponses(request, [ + [ + `txs/${txHash}/cbor`, + { + cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' + } + ], + [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(1)], + [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(0)] + ]); + + const firstResponse = await provider.utxoByAddresses({ addresses: [address] }); + + expect(firstResponse).toBeTruthy(); + expect(firstResponse.length).toBe(1); + + expect(request).toHaveBeenCalled(); + request.mockClear(); + + const secondResponse = await provider.utxoByAddresses({ addresses: [address] }); + + expect(secondResponse).toEqual(firstResponse); + + expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`, undefined); }); - expect(response[0][1]).toMatchObject({ - address, - value: { - assets: new Map([ - [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 1n] - ]), - coins: 50_928_877n + }); + }); + + describe('constructor', () => { + it('accepts old signature (backward compatibility)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); } + }; + + const testProvider = new BlockfrostUtxoProvider({ cache, client, logger }); + + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(false); + }); + + it('accepts new signature with options (queryUtxosByCredentials: false)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + const testProvider = new BlockfrostUtxoProvider({ queryUtxosByCredentials: false }, { cache, client, logger }); + + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(false); + }); + + it('accepts new signature with options (queryUtxosByCredentials: true)', () => { + const client = { request: jest.fn() } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cache: Cache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + const testProvider = new BlockfrostUtxoProvider({ queryUtxosByCredentials: true }, { cache, client, logger }); + + expect(testProvider).toBeInstanceOf(BlockfrostUtxoProvider); + // @ts-expect-error - accessing private field for testing + expect(testProvider.queryUtxosByCredentials).toBe(true); + }); + }); + + describe('with feature flag ON (queryUtxosByCredentials: true)', () => { + let mockRequest: jest.Mock; + let credentialProvider: BlockfrostUtxoProvider; + let credentialCache: Cache; + + // Use real test addresses from CIP-19 test vectors + const baseAddress1 = cip19TestVectors.basePaymentKeyStakeKey; // Base address with payment key + stake key + const baseAddress2 = cip19TestVectors.basePaymentScriptStakeKey; // Base address with script + stake key + const enterpriseAddress = cip19TestVectors.enterpriseKey; // Enterprise address (no stake) + + const generateUtxoForAddress = (addr: Cardano.PaymentAddress, index: number) => ({ + address: addr, + amount: [ + { + quantity: String(50_000_000 + index), + unit: 'lovelace' + } + ], + block: 'b1b23210b9de8f3edef233f21f7d6e1fb93efe124ba126ba924edec3043e75b46', + data_hash: null, + inline_datum: null, + output_index: index, + reference_script_hash: null, + tx_hash: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5', + tx_index: index + }); + + beforeEach(() => { + mockRequest = jest.fn(); + const client = { request: mockRequest } as unknown as BlockfrostClient; + const cacheStorage = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + credentialCache = { + async get(key) { + return cacheStorage.get(key); + }, + async set(key, value) { + cacheStorage.set(key, value); + } + }; + + credentialProvider = new BlockfrostUtxoProvider( + { queryUtxosByCredentials: true }, + { cache: credentialCache, client, logger } + ); + }); + + describe('utxoByAddresses with credential-based queries', () => { + it('single address is minimized to payment credential (1 API call)', async () => { + const credentialUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(baseAddress1, 1)]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + // Verify we queried by payment credential, not by address + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); + + // Should not query by address directly + const addressCalls = mockRequest.mock.calls.filter((call: string[]) => + call[0].includes(`addresses/${baseAddress1}${UTXOS_PATH}`) + ); + expect(addressCalls.length).toBe(0); + + // Should return 2 UTXOs + expect(result).toHaveLength(2); }); - expect(response[1]).toHaveLength(2); - expect(response[1][0]).toMatchObject({ - index: 1, - txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + it('multiple addresses with shared stake key use reward account query', async () => { + // Both addresses share the same stake key + const rewardAccountUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(baseAddress1, 1)]; + + mockRequest.mockImplementation(createMockUtxoHandler(createAccountUtxoHandler(rewardAccountUtxos))); + + // Query with two addresses that share the same stake key + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, baseAddress2] }); + + // Should query by reward account (stake address) + const rewardAccountCalls = mockRequest.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(UTXOS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should not query by payment credentials (minimization prefers reward account) + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBe(0); + + // Should return filtered UTXOs (only those with payment credentials we control) + expect(result.length).toBeGreaterThan(0); }); - expect(response[1][1]).toMatchObject({ - address, - value: { - assets: new Map([ - [Cardano.AssetId('b01fb3b8c3dd6b3705a5dc8bcd5a70759f70ad5d97a72005caeac3c652657675746f31333237'), 2n] - ]), - coins: 50_928_878n - } + + it('filters UTXOs by payment credential in reward account queries', async () => { + // When querying with multiple addresses that share a stake key, + // reward account query returns UTXOs for ALL addresses under that stake key + // We need to filter to only include UTXOs for addresses we queried + const otherAddress = cip19TestVectors.basePaymentScriptStakeKey; // Shares stake key but different payment + const rewardAccountUtxos = [ + generateUtxoForAddress(baseAddress1, 0), + generateUtxoForAddress(baseAddress1, 1), + generateUtxoForAddress(otherAddress, 2) // This should be filtered out (different payment cred) + ]; + + mockRequest.mockImplementation(createMockUtxoHandler(createAccountUtxoHandler(rewardAccountUtxos))); + + // Query with BOTH addresses that share the stake key + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, baseAddress2] }); + + // Should use reward account query (minimization algorithm) + const rewardAccountCalls = mockRequest.mock.calls.filter( + (call: string[]) => call[0].includes(ACCOUNTS_PATH) && call[0].includes(UTXOS_PATH) + ); + expect(rewardAccountCalls.length).toBeGreaterThan(0); + + // Should return filtered UTXOs (filtering done automatically by payment credential filter) + expect(result.length).toBeGreaterThan(0); }); - }); - test('does not call txs/${hash}/cbor when data is cached', async () => { - const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + it('enterprise address uses payment credential query', async () => { + const credentialUtxos = [generateUtxoForAddress(enterpriseAddress, 0)]; - mockResponses(request, [ - [ - `txs/${txHash}/cbor`, - { - cbor: '84a5008182582038685cff32e65bbf5be53e5478b2c9f91c686a663394484df2205d4733d19ad501018182583900bdb17d476dfdaa0d06b970a19e260fc97b9622fa7b85fc51205a552b3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea041a062a3a78021a0002a019031a049dea66048183098200581c3781e8276be4ebdaff285d7a29a144a4cf681af786f441cd8a6eea048102a10082825820518ab6ccb82cf0db3893dc532af0eb27bdfd68d696811ff5acf16c47d4792ab3584018f37f5f94397c10ab44d6d382a0abc83807ab8039b8bff3b172b5875d928cec5022f02d027e4077253a79910126562988c306c34f14dca9d79be4e1c6de940f82582076f79de7b22ea72556735ba30b49a6b176e03641d088f43b64ec299400d971b7584046f4ce5ee2d14ab15c38aa74063bf25c9481ceaec59f9cf55bc25f6aae20a3c8761db8f7e9621c5f6b3f5628c574f738c3c997f83210effd5790d54419254a0ef5f6' - } - ], - [`addresses/${address.toString()}/utxos?page=1&count=100`, generateUtxoResponseMock(1)], - [`addresses/${address.toString()}/utxos?page=2&count=100`, generateUtxoResponseMock(0)] - ]); + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); - const firstResponse = await provider.utxoByAddresses({ addresses: [address] }); + const result = await credentialProvider.utxoByAddresses({ addresses: [enterpriseAddress] }); - expect(firstResponse).toBeTruthy(); - expect(firstResponse.length).toBe(1); + // Should query by payment credential + const credentialCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ADDR_VKH_PREFIX)); + expect(credentialCalls.length).toBeGreaterThan(0); - expect(request).toHaveBeenCalled(); - request.mockClear(); + // Should not query by reward account + const rewardAccountCalls = mockRequest.mock.calls.filter((call: string[]) => call[0].includes(ACCOUNTS_PATH)); + expect(rewardAccountCalls.length).toBe(0); + + expect(result).toHaveLength(1); + }); - const secondResponse = await provider.utxoByAddresses({ addresses: [address] }); + it('deduplicates UTXOs correctly', async () => { + const credentialUtxos = [ + generateUtxoForAddress(baseAddress1, 0), + generateUtxoForAddress(baseAddress1, 0), // Duplicate + generateUtxoForAddress(baseAddress1, 1) + ]; - expect(secondResponse).toEqual(firstResponse); + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); - expect(request).not.toHaveBeenCalledWith(`txs/${txHash}/cbor`, undefined); + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + // Should deduplicate to 2 unique UTXOs + expect(result).toHaveLength(2); + }); + + it('sorts UTXOs by txId and index', async () => { + const utxo1 = generateUtxoForAddress(baseAddress1, 0); + utxo1.tx_hash = '1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const utxo2 = generateUtxoForAddress(baseAddress1, 1); + utxo2.tx_hash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const utxo3 = generateUtxoForAddress(baseAddress1, 0); + utxo3.tx_hash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + + const credentialUtxos = [utxo1, utxo2, utxo3]; + + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler(credentialUtxos))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + expect(result).toHaveLength(3); + // Should be sorted by txId first, then by index + expect(result[0][0].txId).toBe(Cardano.TransactionId(utxo3.tx_hash)); + expect(result[0][0].index).toBe(0); + expect(result[1][0].txId).toBe(Cardano.TransactionId(utxo2.tx_hash)); + expect(result[1][0].index).toBe(1); + expect(result[2][0].txId).toBe(Cardano.TransactionId(utxo1.tx_hash)); + expect(result[2][0].index).toBe(0); + }); + + it('handles empty results', async () => { + mockRequest.mockImplementation(createMockUtxoHandler(createCredentialUtxoHandler([]))); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1] }); + + expect(result).toHaveLength(0); + }); + + it('handles mixed base and enterprise addresses', async () => { + // Note: baseAddress1 and enterpriseAddress share the same payment credential + // so the minimization algorithm will use only 1 payment credential query + const allUtxos = [generateUtxoForAddress(baseAddress1, 0), generateUtxoForAddress(enterpriseAddress, 1)]; + + let paymentCredentialCallCount = 0; + + mockRequest.mockImplementation((url: string) => { + if (url.includes(ADDR_VKH_PREFIX) && url.includes(UTXOS_PATH)) { + paymentCredentialCallCount++; + // Return UTXOs for both addresses since they share the same payment credential + return Promise.resolve(allUtxos); + } + if (url.includes(CBOR_PATH)) { + return Promise.resolve(CBOR_RESPONSE); + } + return Promise.resolve([]); + }); + + const result = await credentialProvider.utxoByAddresses({ addresses: [baseAddress1, enterpriseAddress] }); + + // Both addresses share the same payment credential, + // so only 1 payment credential query is needed (optimal minimization) + expect(paymentCredentialCallCount).toBe(1); + + // Should return UTXOs from both addresses + expect(result).toHaveLength(2); + }); }); }); }); diff --git a/packages/cardano-services-client/test/credentialUtils.test.ts b/packages/cardano-services-client/test/credentialUtils.test.ts new file mode 100644 index 00000000000..60451b67396 --- /dev/null +++ b/packages/cardano-services-client/test/credentialUtils.test.ts @@ -0,0 +1,685 @@ +import { Cardano } from '@cardano-sdk/core'; +import { cip19TestVectors } from '@cardano-sdk/util-dev'; +import { createPaymentCredentialFilter, extractCredentials, minimizeCredentialSet } from '../src/credentialUtils'; + +describe('credentialUtils', () => { + describe('extractCredentials', () => { + describe('BaseAddress with different credential type combinations', () => { + it('extracts payment KeyHash + stake KeyHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + // Should extract one payment credential and one reward account (stake address) + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // RewardAccount (stake address) should have stake/stake_test prefix + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Both should reference the same address + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.basePaymentKeyStakeKey]); + expect(result.rewardAccounts.get(rewardAccount)).toEqual([cip19TestVectors.basePaymentKeyStakeKey]); + + // No skipped addresses + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash + stake KeyHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeKey]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be script (script hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Stake credential should be RewardAccount + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment KeyHash + stake ScriptHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeScript]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // Stake credential should be RewardAccount with stake prefix + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash + stake ScriptHash credentials', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeScript]; + const result = extractCredentials(addresses); + + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Payment credential should be script + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Stake credential should be RewardAccount + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(stakeCred).toMatch(/^stake1/); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('EnterpriseAddress with different credential types', () => { + it('extracts payment KeyHash only (no stake credential)', () => { + const addresses = [cip19TestVectors.enterpriseKey]; + const result = extractCredentials(addresses); + + // Should extract only payment credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + + // Payment credential should be addr_vkh (key hash) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.enterpriseKey]); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('extracts payment ScriptHash only (no stake credential)', () => { + const addresses = [cip19TestVectors.enterpriseScript]; + const result = extractCredentials(addresses); + + // Should extract only payment credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + + // Payment credential should be script + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + expect(result.paymentCredentials.get(paymentCred)).toEqual([cip19TestVectors.enterpriseScript]); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('Special address types', () => { + it('collects Byron addresses in skippedAddresses.byron', () => { + const addresses = [cip19TestVectors.byronMainnetYoroi, cip19TestVectors.byronTestnetDaedalus]; + const result = extractCredentials(addresses); + + // No credentials extracted + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + + // Byron addresses should be in skipped list + expect(result.skippedAddresses.byron).toEqual(addresses); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('collects Pointer addresses in skippedAddresses.pointer', () => { + const addresses = [cip19TestVectors.pointerKey, cip19TestVectors.pointerScript]; + const result = extractCredentials(addresses); + + // No credentials extracted + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + + // Pointer addresses should be in skipped list + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual(addresses); + }); + }); + + describe('Grouping behavior', () => { + it('groups multiple addresses with same payment credential', () => { + // Create two different BaseAddresses that happen to share the same payment credential + // but have different stake credentials (this is a realistic scenario) + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentKeyStakeScript; + + const addresses = [addr1, addr2]; + const result = extractCredentials(addresses); + + // Should have one payment credential (shared) + expect(result.paymentCredentials.size).toBe(1); + + // Should have two stake credentials (different) + expect(result.rewardAccounts.size).toBe(2); + + // Payment credential should reference both addresses + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(2); + expect(result.paymentCredentials.get(paymentCred)).toContain(addr1); + expect(result.paymentCredentials.get(paymentCred)).toContain(addr2); + }); + + it('groups multiple addresses with same stake credential', () => { + // basePaymentKeyStakeKey and basePaymentScriptStakeKey share the same stake credential + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + + const addresses = [addr1, addr2]; + const result = extractCredentials(addresses); + + // Should have two payment credentials (different) + expect(result.paymentCredentials.size).toBe(2); + + // Should have one stake credential (shared) + expect(result.rewardAccounts.size).toBe(1); + + // Stake credential should reference both addresses + const stakeCred = [...result.rewardAccounts.keys()][0]; + expect(result.rewardAccounts.get(stakeCred)).toHaveLength(2); + expect(result.rewardAccounts.get(stakeCred)).toContain(addr1); + expect(result.rewardAccounts.get(stakeCred)).toContain(addr2); + }); + + it('handles mixed BaseAddress and EnterpriseAddress', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.enterpriseKey]; + const result = extractCredentials(addresses); + + // Both addresses share the same payment key hash, so only one payment credential + expect(result.paymentCredentials.size).toBe(1); + + // Should have one stake credential (only from BaseAddress) + expect(result.rewardAccounts.size).toBe(1); + + // Both addresses should be grouped under the same payment credential + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(2); + + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + }); + + describe('Bech32 encoding verification', () => { + it('encodes payment KeyHash credentials as addr_vkh', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^addr_vkh1/); + + // Verify it's valid PaymentCredential + expect(() => Cardano.PaymentCredential(paymentCred)).not.toThrow(); + }); + + it('encodes payment ScriptHash credentials as script', () => { + const addresses = [cip19TestVectors.basePaymentScriptStakeKey]; + const result = extractCredentials(addresses); + + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(paymentCred).toMatch(/^script1/); + + // Verify it's valid PaymentCredential + expect(() => Cardano.PaymentCredential(paymentCred)).not.toThrow(); + }); + + it('encodes reward accounts (stake addresses) with stake prefix for KeyHash', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes reward accounts (stake addresses) with stake prefix for ScriptHash', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeScript]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes mainnet reward accounts with "stake" prefix', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake1/); + expect(rewardAccount).not.toMatch(/^stake_test/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + + it('encodes testnet reward accounts with "stake_test" prefix', () => { + const addresses = [cip19TestVectors.testnetBasePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + const rewardAccount = [...result.rewardAccounts.keys()][0]; + expect(rewardAccount).toMatch(/^stake_test1/); + + // Verify it's valid RewardAccount + expect(() => Cardano.RewardAccount(rewardAccount)).not.toThrow(); + }); + }); + + describe('Edge cases', () => { + it('handles empty address array', () => { + const result = extractCredentials([]); + + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + expect(result.skippedAddresses.byron).toEqual([]); + expect(result.skippedAddresses.pointer).toEqual([]); + }); + + it('handles duplicate addresses', () => { + const addresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.basePaymentKeyStakeKey]; + const result = extractCredentials(addresses); + + // Should still have one of each credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(1); + + // Address should appear once in the list (deduplicated) + const paymentCred = [...result.paymentCredentials.keys()][0]; + expect(result.paymentCredentials.get(paymentCred)).toHaveLength(1); + }); + }); + }); + + describe('minimizeCredentialSet', () => { + it('Scenario 1: Single address (BaseAddress) - prefer payment credential', () => { + // Single BaseAddress has both payment and stake credentials + // Should prefer payment credential over stake when both cover the same address + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addressGroups = extractCredentials([addr1]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep payment credential, remove stake credential + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 2: Single address (EnterpriseAddress) - only payment credential', () => { + // EnterpriseAddress has only payment credential + const addr1 = cip19TestVectors.enterpriseKey; + const addressGroups = extractCredentials([addr1]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep payment credential (only option) + expect(result.paymentCredentials.size).toBe(1); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 3: 2 addresses with same stake credential, different payment credentials', () => { + // Two addresses share stake credential but have different payment credentials + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + const addressGroups = extractCredentials([addr1, addr2]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Stake credential covers both addresses, so it should be preferred + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(1); + }); + + it('Scenario 4: prefers payment credentials when coverage is equal (3 addresses, overlapping credentials)', () => { + // Three addresses: + // addr1 = payment KEY + stake KEY + // addr2 = payment SCRIPT + stake KEY (shares stake with addr1) + // addr3 = payment KEY + stake SCRIPT (shares payment with addr1) + const addr1 = cip19TestVectors.basePaymentKeyStakeKey; + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; + const addr3 = cip19TestVectors.basePaymentKeyStakeScript; + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Payment KEY covers addr1 and addr3 (2 addresses) + // Payment SCRIPT covers addr2 (1 address) + // Stake KEY covers addr1 and addr2 (2 addresses) + // Stake SCRIPT covers addr3 (1 address) + // Both approaches need 2 credentials, but greedy algorithm prefers payment credentials + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 5: All EnterpriseAddresses with different payment credentials', () => { + // All addresses are EnterpriseAddress + // Note: enterpriseKey and testnetEnterpriseKey share the same key hash + // Payment credentials don't include network ID (per CIP-5), so they have the same credential + const addr1 = cip19TestVectors.enterpriseKey; + const addr2 = cip19TestVectors.enterpriseScript; + const addr3 = cip19TestVectors.testnetEnterpriseScript; + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // Should keep all payment credentials (no stake credentials to optimize) + // addr1 uses key hash, addr2 uses script hash (mainnet), addr3 uses script hash (testnet) + // Since payment credentials don't include network, addr2 and addr3 share the same credential + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('Scenario 6: Mixed BaseAddress and EnterpriseAddress', () => { + // Mix of BaseAddress and EnterpriseAddress with different payment credentials + const addr1 = cip19TestVectors.enterpriseKey; // Enterprise (payment1) + const addr2 = cip19TestVectors.basePaymentScriptStakeKey; // Base (paymentScript + stake1) + const addr3 = cip19TestVectors.basePaymentScriptStakeScript; // Base (paymentScript + stake2) + // addr2 and addr3 share the same payment credential (script hash) + + const addressGroups = extractCredentials([addr1, addr2, addr3]); + + const result = minimizeCredentialSet({ + paymentCredentials: addressGroups.paymentCredentials, + rewardAccounts: addressGroups.rewardAccounts + }); + + // payment1 covers addr1 (1 address) + // paymentScript covers addr2 + addr3 (2 addresses) - prefer over stake1+stake2 + expect(result.paymentCredentials.size).toBe(2); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('handles empty credential maps', () => { + const result = minimizeCredentialSet({ + paymentCredentials: new Map(), + rewardAccounts: new Map() + }); + + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + }); + + it('handles credentials with no addresses', () => { + const result = minimizeCredentialSet({ + paymentCredentials: new Map(), + rewardAccounts: new Map([[Cardano.RewardAccount(cip19TestVectors.rewardKey), []]]) + }); + + // Credential with no addresses should be removed + expect(result.paymentCredentials.size).toBe(0); + expect(result.rewardAccounts.size).toBe(0); + }); + }); + + describe('createPaymentCredentialFilter', () => { + // Constants for commonly used test inputs + const BASE_KEY_STAKE_KEY_INPUT = [cip19TestVectors.basePaymentKeyStakeKey]; + const BASE_SCRIPT_STAKE_KEY_INPUT = [cip19TestVectors.basePaymentScriptStakeKey]; + const ENTERPRISE_KEY_INPUT = [cip19TestVectors.enterpriseKey]; + const ENTERPRISE_SCRIPT_INPUT = [cip19TestVectors.enterpriseScript]; + const POINTER_KEY_INPUT = [cip19TestVectors.pointerKey]; + const POINTER_SCRIPT_INPUT = [cip19TestVectors.pointerScript]; + + // Constants for test description strings + const WITH_KEY_HASH = 'with KeyHash payment credential'; + const WITH_SCRIPT_HASH = 'with ScriptHash payment credential'; + const EXACT_MATCH_TEST = 'passes filter for address with controlled payment credential (exact match)'; + + describe('BaseAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + // Same address should pass (fast path - exact match) + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + }); + + it('passes filter for address with same payment credential but different stake credential', () => { + // Different address with same payment credential should pass + // basePaymentKeyStakeKey has payment key hash, basePaymentKeyStakeScript has same payment key hash but different stake + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + }); + + it('rejects addresses with uncontrolled payment credentials', () => { + // basePaymentScriptStakeKey has different payment credential (script vs key hash) + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(false); + // Address with same stake credential but different payment credential should also be rejected + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(BASE_SCRIPT_STAKE_KEY_INPUT); + + it('passes filter for addresses with same payment credential', () => { + // Same payment credential (script) should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + // Different stake but same payment credential (script) should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(true); + }); + }); + }); + + describe('EnterpriseAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(ENTERPRISE_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('rejects address with uncontrolled payment credential', () => { + // enterpriseScript has different payment credential + expect(filter(cip19TestVectors.enterpriseScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(ENTERPRISE_SCRIPT_INPUT); + + it('passes filter for script payment credential', () => { + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + }); + }); + + it('passes filter when EnterpriseAddress matches payment credential from BaseAddress', () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + // enterpriseKey should have the same payment credential as basePaymentKeyStakeKey + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + }); + + describe('PointerAddress filtering', () => { + describe(WITH_KEY_HASH, () => { + const filter = createPaymentCredentialFilter(POINTER_KEY_INPUT); + + it(EXACT_MATCH_TEST, () => { + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + }); + + it('rejects address with uncontrolled payment credential', () => { + // pointerScript has different payment credential + expect(filter(cip19TestVectors.pointerScript)).toBe(false); + }); + }); + + describe(WITH_SCRIPT_HASH, () => { + const filter = createPaymentCredentialFilter(POINTER_SCRIPT_INPUT); + + it('passes filter for script payment credential', () => { + expect(filter(cip19TestVectors.pointerScript)).toBe(true); + }); + }); + + it('passes filter when PointerAddress matches payment credential from BaseAddress', () => { + const filter = createPaymentCredentialFilter(BASE_KEY_STAKE_KEY_INPUT); + + // pointerKey should have the same payment credential as basePaymentKeyStakeKey + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + }); + }); + + describe('Mixed credential types', () => { + it('correctly filters KeyHash payment credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.enterpriseKey, + cip19TestVectors.pointerKey + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // All addresses with same key hash should pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.pointerKey)).toBe(true); + + // Addresses with script hash should not pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(false); + expect(filter(cip19TestVectors.pointerScript)).toBe(false); + }); + + it('correctly filters ScriptHash payment credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentScriptStakeKey, + cip19TestVectors.enterpriseScript, + cip19TestVectors.pointerScript + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // All addresses with same script hash should pass + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentScriptStakeScript)).toBe(true); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + expect(filter(cip19TestVectors.pointerScript)).toBe(true); + + // Addresses with key hash should not pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(false); + expect(filter(cip19TestVectors.pointerKey)).toBe(false); + }); + + it('correctly filters mixed KeyHash and ScriptHash credentials', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, // key hash payment + cip19TestVectors.basePaymentScriptStakeKey // script hash payment + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Addresses with either controlled payment credential should pass + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.basePaymentScriptStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseScript)).toBe(true); + }); + }); + + describe('Edge cases', () => { + it('rejects all addresses when input is empty', () => { + const filter = createPaymentCredentialFilter([]); + + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(false); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(false); + expect(filter(cip19TestVectors.pointerKey)).toBe(false); + }); + + it('skips Byron addresses in filter input (cannot extract payment credential)', () => { + const inputAddresses = [cip19TestVectors.byronMainnetYoroi, cip19TestVectors.basePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Byron address itself should pass (exact match) + expect(filter(cip19TestVectors.byronMainnetYoroi)).toBe(true); + // Other addresses with key hash should still work + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('rejects Byron address when checking if it matches payment credentials', () => { + const inputAddresses = [cip19TestVectors.basePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Byron address should not pass (cannot extract payment credential, no exact match) + expect(filter(cip19TestVectors.byronMainnetYoroi)).toBe(false); + }); + + it('handles duplicate addresses in input', () => { + const inputAddresses = [ + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.basePaymentKeyStakeKey, + cip19TestVectors.enterpriseKey + ]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Should work correctly despite duplicates + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + }); + + it('handles testnet addresses correctly', () => { + const inputAddresses = [cip19TestVectors.testnetBasePaymentKeyStakeKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // Same payment credential on testnet should pass + expect(filter(cip19TestVectors.testnetBasePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.testnetEnterpriseKey)).toBe(true); + expect(filter(cip19TestVectors.testnetPointerKey)).toBe(true); + + // Different payment credential should not pass + expect(filter(cip19TestVectors.testnetBasePaymentScriptStakeKey)).toBe(false); + }); + }); + + describe('Performance optimization - exact address match', () => { + it('uses fast path for exact address matches', () => { + const inputAddresses = [cip19TestVectors.basePaymentKeyStakeKey, cip19TestVectors.enterpriseKey]; + const filter = createPaymentCredentialFilter(inputAddresses); + + // These should use the fast path (exact match in Set) + expect(filter(cip19TestVectors.basePaymentKeyStakeKey)).toBe(true); + expect(filter(cip19TestVectors.enterpriseKey)).toBe(true); + + // This should use the credential extraction path (not in exact match Set) + expect(filter(cip19TestVectors.basePaymentKeyStakeScript)).toBe(true); + }); + }); + }); +}); diff --git a/packages/core/src/Cardano/Address/PaymentCredential.ts b/packages/core/src/Cardano/Address/PaymentCredential.ts new file mode 100644 index 00000000000..f36f277747d --- /dev/null +++ b/packages/core/src/Cardano/Address/PaymentCredential.ts @@ -0,0 +1,30 @@ +import * as BaseEncoding from '@scure/base'; +import { Credential, CredentialType } from './Address'; +import { OpaqueString, typedBech32 } from '@cardano-sdk/util'; + +/** Payment credential as bech32 (addr_vkh for key hash, script for script hash - per CIP-5) */ +export type PaymentCredential = OpaqueString<'PaymentCredential'>; + +/** + * @param {string} value payment credential as bech32 string (addr_vkh for key hash, script for script hash) + * @throws InvalidStringError + */ +export const PaymentCredential = (value: string): PaymentCredential => { + try { + return typedBech32(value, ['addr_vkh'], 45); + } catch { + return typedBech32(value, ['script'], 45); + } +}; + +/** + * Converts address Credential to bech32 PaymentCredential. + * + * @param credential The credential to convert. + * @returns The payment credential as bech32 string. + */ +PaymentCredential.fromCredential = (credential: Credential): PaymentCredential => { + const words = BaseEncoding.bech32.toWords(Buffer.from(credential.hash, 'hex')); + const prefix = credential.type === CredentialType.KeyHash ? 'addr_vkh' : 'script'; + return BaseEncoding.bech32.encode(prefix, words, 1023) as PaymentCredential; +}; diff --git a/packages/core/src/Cardano/Address/index.ts b/packages/core/src/Cardano/Address/index.ts index 4c6958ee690..3b09b537a33 100644 --- a/packages/core/src/Cardano/Address/index.ts +++ b/packages/core/src/Cardano/Address/index.ts @@ -4,6 +4,7 @@ export * from './ByronAddress'; export * from './DRepID'; export * from './EnterpriseAddress'; export * from './PaymentAddress'; +export * from './PaymentCredential'; export * from './PointerAddress'; export * from './RewardAccount'; export * from './RewardAddress'; diff --git a/packages/core/src/util/utxo.ts b/packages/core/src/util/utxo.ts index a7ef2732975..1a272254f56 100644 --- a/packages/core/src/util/utxo.ts +++ b/packages/core/src/util/utxo.ts @@ -1 +1,24 @@ +import type { TxIn, Utxo } from '../Cardano'; + export const createUtxoId = (txHash: string, index: number) => `${txHash}:${index}`; + +/** + * Sorts the given TxIn set first by txId and then by index. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The right-hand side of the comparison operation. + */ +export const sortTxIn = (lhs: TxIn, rhs: TxIn) => { + const txIdComparison = lhs.txId.localeCompare(rhs.txId); + if (txIdComparison !== 0) return txIdComparison; + + return lhs.index - rhs.index; +}; + +/** + * Sorts the given Utxo set first by TxIn. + * + * @param lhs The left-hand side of the comparison operation. + * @param rhs The right-hand side of the comparison operation. + */ +export const sortUtxoByTxIn = (lhs: Utxo, rhs: Utxo) => sortTxIn(lhs[0], rhs[0]); diff --git a/packages/core/test/Cardano/Address/PaymentCredential.test.ts b/packages/core/test/Cardano/Address/PaymentCredential.test.ts new file mode 100644 index 00000000000..55beddba305 --- /dev/null +++ b/packages/core/test/Cardano/Address/PaymentCredential.test.ts @@ -0,0 +1,66 @@ +import * as cip19TestVectors from '../../../../util-dev/src/Cip19TestVectors'; +import { Cardano } from '../../../src'; +import { typedBech32 } from '@cardano-sdk/util'; + +jest.mock('@cardano-sdk/util', () => { + const actual = jest.requireActual('@cardano-sdk/util'); + return { + ...actual, + typedBech32: jest.fn().mockImplementation((...args) => actual.typedBech32(...args)) + }; +}); + +describe('Cardano/Address/PaymentCredential', () => { + const validAddrVkh = 'addr_vkh1an33fpxnkj09r8upnlw55a59kcysk8u3jcm00rfu44dk75ut34v'; + const validScript = 'script1cda3khwqv60360rp5m7akt50m6ttapacs8rqhn5w342z7r35m37'; + + describe('PaymentCredential()', () => { + it('accepts a valid addr_vkh bech32 for key hash credential', () => { + expect(() => Cardano.PaymentCredential(validAddrVkh)).not.toThrow(); + }); + + it('accepts a valid script bech32 for script hash credential', () => { + expect(() => Cardano.PaymentCredential(validScript)).not.toThrow(); + }); + + it('is implemented using util.typedBech32 for addr_vkh', () => { + Cardano.PaymentCredential(validAddrVkh); + expect(typedBech32).toHaveBeenCalledWith(validAddrVkh, ['addr_vkh'], 45); + }); + + it('throws on invalid bech32 prefix (stake address)', () => { + expect(() => Cardano.PaymentCredential('stake1u89sasnfyjtmgk8ydqfv3fdl52f36x3djedfnzfc9rkgzrcss5vgr')).toThrow(); + }); + }); + + describe('fromCredential', () => { + it('converts key hash credential to addr_vkh bech32', () => { + const paymentCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + expect(paymentCredential).toMatch(/^addr_vkh1/); + // Verify it can be parsed back + expect(() => Cardano.PaymentCredential(paymentCredential)).not.toThrow(); + }); + + it('converts script hash credential to script bech32', () => { + const paymentCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.SCRIPT_CREDENTIAL); + expect(paymentCredential).toMatch(/^script1/); + // Verify it can be parsed back + expect(() => Cardano.PaymentCredential(paymentCredential)).not.toThrow(); + }); + + it('produces valid bech32 with correct length', () => { + const keyHashCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + const scriptHashCredential = Cardano.PaymentCredential.fromCredential(cip19TestVectors.SCRIPT_CREDENTIAL); + + // Both should be 56+ characters (prefix + separator + encoded data + checksum) + expect(keyHashCredential.length).toBeGreaterThan(56); + expect(scriptHashCredential.length).toBeGreaterThan(56); + }); + + it('is consistent - same credential produces same bech32', () => { + const cred1 = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + const cred2 = Cardano.PaymentCredential.fromCredential(cip19TestVectors.KEY_PAYMENT_CREDENTIAL); + expect(cred1).toBe(cred2); + }); + }); +}); diff --git a/packages/core/test/util/utxo.test.ts b/packages/core/test/util/utxo.test.ts new file mode 100644 index 00000000000..9f5d93c9f58 --- /dev/null +++ b/packages/core/test/util/utxo.test.ts @@ -0,0 +1,190 @@ +import { Cardano, createUtxoId, sortTxIn, sortUtxoByTxIn } from '../../src'; + +describe('util/utxo', () => { + describe('createUtxoId', () => { + it('creates a UTXO ID from hash and index', () => { + const txHash = '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'; + const index = 0; + + expect(createUtxoId(txHash, index)).toBe('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5:0'); + }); + + it('handles different indices', () => { + const txHash = '1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477'; + + expect(createUtxoId(txHash, 0)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:0'); + expect(createUtxoId(txHash, 1)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:1'); + expect(createUtxoId(txHash, 99)).toBe('1e043f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477:99'); + }); + }); + + describe('sortTxIn', () => { + it('sorts by txId first (ascending)', () => { + const txIn1: Cardano.TxIn = { + index: 0, + txId: Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }; + const txIn2: Cardano.TxIn = { + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }; + + expect(sortTxIn(txIn1, txIn2)).toBeGreaterThan(0); + expect(sortTxIn(txIn2, txIn1)).toBeLessThan(0); + }); + + it('sorts by index when txId is the same', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txIn1: Cardano.TxIn = { index: 1, txId }; + const txIn2: Cardano.TxIn = { index: 0, txId }; + const txIn3: Cardano.TxIn = { index: 2, txId }; + + expect(sortTxIn(txIn1, txIn2)).toBeGreaterThan(0); + expect(sortTxIn(txIn2, txIn1)).toBeLessThan(0); + expect(sortTxIn(txIn3, txIn1)).toBeGreaterThan(0); + }); + + it('returns 0 for identical TxIn', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txIn1: Cardano.TxIn = { index: 0, txId }; + const txIn2: Cardano.TxIn = { index: 0, txId }; + + expect(sortTxIn(txIn1, txIn2)).toBe(0); + }); + + it('can be used with Array.sort to sort TxIn array', () => { + const txId1 = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txId2 = Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + + const inputs: Cardano.TxIn[] = [ + { index: 1, txId: txId1 }, + { index: 0, txId: txId2 }, + { index: 0, txId: txId1 }, + { index: 2, txId: txId1 } + ]; + + inputs.sort(sortTxIn); + + expect(inputs[0]).toEqual({ index: 0, txId: txId1 }); + expect(inputs[1]).toEqual({ index: 1, txId: txId1 }); + expect(inputs[2]).toEqual({ index: 2, txId: txId1 }); + expect(inputs[3]).toEqual({ index: 0, txId: txId2 }); + }); + }); + + describe('sortUtxoByTxIn', () => { + const address = Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + + it('sorts UTXOs by txId first (ascending)', () => { + const utxo1: Cardano.Utxo = [ + { + address, + index: 0, + txId: Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { + address, + index: 0, + txId: Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5') + }, + { address, value: { coins: 2_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBeGreaterThan(0); + expect(sortUtxoByTxIn(utxo2, utxo1)).toBeLessThan(0); + }); + + it('sorts UTXOs by index when txId is the same', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const utxo1: Cardano.Utxo = [ + { address, index: 1, txId }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 2_000_000n } } + ]; + const utxo3: Cardano.Utxo = [ + { address, index: 2, txId }, + { address, value: { coins: 3_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBeGreaterThan(0); + expect(sortUtxoByTxIn(utxo2, utxo1)).toBeLessThan(0); + expect(sortUtxoByTxIn(utxo3, utxo1)).toBeGreaterThan(0); + }); + + it('returns 0 for identical UTXOs (same TxIn)', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const utxo1: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address, index: 0, txId }, + { address, value: { coins: 2_000_000n } } + ]; + + expect(sortUtxoByTxIn(utxo1, utxo2)).toBe(0); + }); + + it('can be used with Array.sort to sort UTXO array', () => { + const txId1 = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const txId2 = Cardano.TransactionId('1f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + + const utxos: Cardano.Utxo[] = [ + [ + { address, index: 1, txId: txId1 }, + { address, value: { coins: 1_000_000n } } + ], + [ + { address, index: 0, txId: txId2 }, + { address, value: { coins: 2_000_000n } } + ], + [ + { address, index: 0, txId: txId1 }, + { address, value: { coins: 3_000_000n } } + ], + [ + { address, index: 2, txId: txId1 }, + { address, value: { coins: 4_000_000n } } + ] + ]; + + utxos.sort(sortUtxoByTxIn); + + // Should be sorted by txId first, then by index + expect(utxos[0][0]).toEqual({ address, index: 0, txId: txId1 }); + expect(utxos[1][0]).toEqual({ address, index: 1, txId: txId1 }); + expect(utxos[2][0]).toEqual({ address, index: 2, txId: txId1 }); + expect(utxos[3][0]).toEqual({ address, index: 0, txId: txId2 }); + }); + + it('handles UTXOs with different addresses but same TxIn', () => { + const txId = Cardano.TransactionId('0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5'); + const address1 = Cardano.PaymentAddress( + 'addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp' + ); + const address2 = Cardano.PaymentAddress( + 'addr_test1qra788mu4sg8kwd93ns9nfdh3k4ufxwg4xhz2r3n064tzfgxu2hyfhlkwuxupa9d5085eunq2qywy7hvmvej456flkns6cy45x' + ); + + const utxo1: Cardano.Utxo = [ + { address: address1, index: 0, txId }, + { address: address1, value: { coins: 1_000_000n } } + ]; + const utxo2: Cardano.Utxo = [ + { address: address2, index: 0, txId }, + { address: address2, value: { coins: 2_000_000n } } + ]; + + // Should return 0 because TxIn is the same (sorting only considers TxIn, not TxOut) + expect(sortUtxoByTxIn(utxo1, utxo2)).toBe(0); + }); + }); +}); diff --git a/packages/input-selection/src/util.ts b/packages/input-selection/src/util.ts index 4404e146aee..2ea3d6793aa 100644 --- a/packages/input-selection/src/util.ts +++ b/packages/input-selection/src/util.ts @@ -1,7 +1,7 @@ /* eslint-disable func-style, complexity, sonarjs/cognitive-complexity */ import { BigIntMath } from '@cardano-sdk/util'; -import { Cardano } from '@cardano-sdk/core'; +import { Cardano, sortTxIn, sortUtxoByTxIn } from '@cardano-sdk/core'; import { ComputeMinimumCoinQuantity, ImplicitValue, TokenBundleSizeExceedsLimit } from './types'; import { InputSelectionError, InputSelectionFailure } from './InputSelectionError'; import uniq from 'lodash/uniq.js'; @@ -12,26 +12,8 @@ export const stubMaxSizeAddress = Cardano.PaymentAddress( 'addr_test1qqydn46r6mhge0kfpqmt36m6q43knzsd9ga32n96m89px3nuzcjqw982pcftgx53fu5527z2cj2tkx2h8ux2vxsg475qypp3m9' ); -/** - * Sorts the given TxIn set first by txId and then by index. - * - * @param lhs The left-hand side of the comparison operation. - * @param rhs The left-hand side of the comparison operation. - */ -export const sortTxIn = (lhs: Cardano.TxIn, rhs: Cardano.TxIn) => { - const txIdComparison = lhs.txId.localeCompare(rhs.txId); - if (txIdComparison !== 0) return txIdComparison; - - return lhs.index - rhs.index; -}; - -/** - * Sorts the given Utxo set first by TxIn. - * - * @param lhs The left-hand side of the comparison operation. - * @param rhs The left-hand side of the comparison operation. - */ -export const sortUtxoByTxIn = (lhs: Cardano.Utxo, rhs: Cardano.Utxo) => sortTxIn(lhs[0], rhs[0]); +// Re-export sorting utilities from core for backward compatibility +export { sortTxIn, sortUtxoByTxIn }; export interface ImplicitTokens { spend(assetId: Cardano.AssetId): bigint; diff --git a/packages/wallet/src/global.d.ts b/packages/wallet/src/global.d.ts index 2c41c10ca9d..167c831250e 100644 --- a/packages/wallet/src/global.d.ts +++ b/packages/wallet/src/global.d.ts @@ -1,15 +1,9 @@ -export {} +export {}; declare global { interface Array { - findLastIndex( - predicate: (value: T, index: number, obj: T[]) => unknown, - thisArg?: any - ): number; + findLastIndex(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): number; - findLast( - predicate: (value: T, index: number, obj: T[]) => unknown, - thisArg?: any - ): T | undefined; + findLast(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T | undefined; } } diff --git a/packages/wallet/src/services/TransactionsTracker.ts b/packages/wallet/src/services/TransactionsTracker.ts index f950a7201a3..bf65257803c 100644 --- a/packages/wallet/src/services/TransactionsTracker.ts +++ b/packages/wallet/src/services/TransactionsTracker.ts @@ -37,7 +37,6 @@ import { distinctBlock, pollProvider, signedTxsEquals, transactionsEquals, txEqu import { WitnessedTx } from '@cardano-sdk/key-management'; import { newAndStoredMulticast } from './util/newAndStoredMulticast'; -import chunk from 'lodash/chunk.js'; import sortBy from 'lodash/sortBy.js'; export interface TransactionsTrackerProps { @@ -74,9 +73,6 @@ export interface TransactionsTrackerInternalsProps { logger: Logger; } -// Temporarily hardcoded. Will be replaced with ChainHistoryProvider 'maxPageSize' value once ADP-2249 is implemented -export const PAGE_SIZE = 25; - /** * Sorts the given HydratedTx by slot. * @@ -121,33 +117,30 @@ const allTransactionsByAddresses = async ( filterBy: { type: 'blockRange'; blockRange: Range } | { type: 'tip'; limit: number }; } ): Promise => { - const addressesSubGroups = chunk(addresses, PAGE_SIZE); let response: Cardano.HydratedTx[] = []; - for (const addressGroup of addressesSubGroups) { - if (filterBy.type === 'blockRange') { - let startAt = 0; - let pageResults: Cardano.HydratedTx[] = []; - - do { - pageResults = ( - await chainHistoryProvider.transactionsByAddresses({ - addresses: addressGroup, - blockRange: filterBy.blockRange, - pagination: { limit: PAGE_SIZE, startAt } - }) - ).pageResults; - - startAt += PAGE_SIZE; - response = [...response, ...pageResults]; - } while (pageResults.length >= PAGE_SIZE); - } else { - const txes = await chainHistoryProvider.transactionsByAddresses({ - addresses: addressGroup, - pagination: { limit: filterBy.limit, order: 'desc', startAt: 0 } - }); - response = [...response, ...txes.pageResults.reverse()]; - } + if (filterBy.type === 'blockRange') { + let startAt = 0; + let pageResults: Cardano.HydratedTx[] = []; + + do { + pageResults = ( + await chainHistoryProvider.transactionsByAddresses({ + addresses, + blockRange: filterBy.blockRange, + pagination: { limit: 25, startAt } + }) + ).pageResults; + + startAt += 25; + response = [...response, ...pageResults]; + } while (pageResults.length >= 25); + } else { + const txes = await chainHistoryProvider.transactionsByAddresses({ + addresses, + pagination: { limit: filterBy.limit, order: 'desc', startAt: 0 } + }); + response = [...response, ...txes.pageResults.reverse()]; } return deduplicateSortedArray(response.sort(compareTxOrder), txEquals); diff --git a/packages/wallet/src/services/UtxoTracker.ts b/packages/wallet/src/services/UtxoTracker.ts index 4c836dbf8d4..a9dba113751 100644 --- a/packages/wallet/src/services/UtxoTracker.ts +++ b/packages/wallet/src/services/UtxoTracker.ts @@ -6,12 +6,8 @@ import { RetryBackoffConfig } from 'backoff-rxjs'; import { TxInFlight, UtxoTracker } from './types'; import { WalletStores } from '../persistence'; import { sortUtxoByTxIn } from '@cardano-sdk/input-selection'; -import chunk from 'lodash/chunk.js'; import uniqWith from 'lodash/uniqWith.js'; -// Temporarily hardcoded. Will be replaced with ChainHistoryProvider 'maxPageSize' value once ADP-2249 is implemented -const PAGE_SIZE = 25; - export interface UtxoTrackerProps { utxoProvider: UtxoProvider; addresses$: Observable; @@ -41,14 +37,7 @@ export const createUtxoProvider = ( logger, retryBackoffConfig, sample: async () => { - let utxos = new Array(); - - const addressesSubGroups = chunk(paymentAddresses, PAGE_SIZE); - - for (const addresses of addressesSubGroups) { - utxos = [...utxos, ...(await utxoProvider.utxoByAddresses({ addresses }))]; - } - + const utxos = await utxoProvider.utxoByAddresses({ addresses: paymentAddresses }); return utxos.sort(sortUtxoByTxIn); }, trigger$: history$ diff --git a/packages/wallet/test/services/TransactionsTracker.test.ts b/packages/wallet/test/services/TransactionsTracker.test.ts index 7110e8cb0e8..c67e96e1312 100644 --- a/packages/wallet/test/services/TransactionsTracker.test.ts +++ b/packages/wallet/test/services/TransactionsTracker.test.ts @@ -5,7 +5,6 @@ import { Cardano, ChainHistoryProvider, TransactionsByAddressesArgs } from '@car import { FailedTx, OutgoingTx, - PAGE_SIZE, TransactionFailure, TxInFlight, createAddressTransactionsProvider, @@ -138,7 +137,7 @@ describe('TransactionsTracker', () => { }); it('emits configured number of latest historical transactions', async () => { - const totalTxsCount = PAGE_SIZE + 5; + const totalTxsCount = 25 + 5; const allTransactions = generateTxAlonzo(totalTxsCount); chainHistoryProvider.transactionsByAddresses = jest