From 671e8bfe5431506c110626fd58fd4635fa0a1eef Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 7 Nov 2025 15:02:02 +0100 Subject: [PATCH 1/2] fix(cardano-services-client): respect pagination limit in credential-based transaction queries When queryTxsByCredentials is enabled, fetchTransactionsByPaymentCredential and fetchTransactionsByRewardAccount were fetching ALL pages from Blockfrost instead of stopping once enough transactions were retrieved. This caused unnecessary API calls and performance issues. Changes: - Add FetchTransactionsOptions interface to document and deduplicate options - Implement proper haveEnoughItems logic (was: () => false, now: checks limit) - Extract common pagination logic into fetchTransactionsWithPagination - Pass limit parameter through the call chain - Cap page size at 100 (Blockfrost's max) to enable proper pagination The fix ensures credential-based queries behave identically to address-based queries, fetching only the necessary pages to satisfy the requested limit. --- .../BlockfrostChainHistoryProvider.ts | 136 ++++++++---------- .../BlockfrostChainHistoryProvider.test.ts | 62 ++++++++ 2 files changed, 120 insertions(+), 78 deletions(-) diff --git a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts index ebd6e2fe16f..ebf0704d408 100644 --- a/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts +++ b/packages/cardano-services-client/src/ChainHistoryProvider/BlockfrostChainHistoryProvider.ts @@ -35,6 +35,28 @@ 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; +/** Options for fetching transactions with pagination and filtering. */ +interface FetchTransactionsOptions { + /** Pagination options for controlling page size and order */ + pagination?: { + /** Number of results per page */ + count: number; + /** Sort order (ascending or descending) */ + order?: 'asc' | 'desc'; + /** Page number to fetch */ + page: number; + }; + /** Block range filters for limiting results by block height */ + blockRange?: { + /** Lower bound for block height (inclusive) */ + lowerBound?: number; + /** Upper bound for block height (inclusive) */ + upperBound?: number; + }; + /** Maximum number of transactions to fetch across all pages */ + limit?: number; +} + interface BlockfrostChainHistoryProviderOptions { queryTxsByCredentials?: boolean; } @@ -610,11 +632,12 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement private async fetchAllByPaymentCredentials( credentials: Map, pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, - blockRange?: { lowerBound?: number; upperBound?: number } + blockRange?: { lowerBound?: number; upperBound?: number }, + limit?: number ): Promise { const results = await Promise.all( [...credentials.keys()].map((credential) => - this.fetchTransactionsByPaymentCredential(credential, { blockRange, pagination }) + this.fetchTransactionsByPaymentCredential(credential, { blockRange, limit, pagination }) ) ); return results.flat(); @@ -624,11 +647,12 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement private async fetchAllByRewardAccounts( rewardAccounts: Map, pagination?: { count: number; order?: 'asc' | 'desc'; page: number }, - blockRange?: { lowerBound?: number; upperBound?: number } + blockRange?: { lowerBound?: number; upperBound?: number }, + limit?: number ): Promise { const results = await Promise.all( [...rewardAccounts.keys()].map((rewardAccount) => - this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, pagination }) + this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, limit, pagination }) ) ); return results.flat(); @@ -667,7 +691,7 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement const paginationOptions = pagination ? { - count: pagination.limit, + count: Math.min(pagination.limit, 100), // Cap at Blockfrost's max page size order: pagination.order ?? 'asc', page: (pagination.startAt + pagination.limit) / pagination.limit } @@ -677,8 +701,8 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement 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.fetchAllByPaymentCredentials(minimized.paymentCredentials, paginationOptions, blockRangeOptions, limit), + this.fetchAllByRewardAccounts(minimized.rewardAccounts, paginationOptions, blockRangeOptions, limit), this.fetchSkippedAddresses(addressGroups.skippedAddresses, paginationOptions, blockRangeOptions, limit) ]); @@ -787,27 +811,15 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement } /** - * Fetches transactions for a given address using the fetchSequentially pattern. + * Common method to fetch transactions with pagination and limits. * - * @param address - The address to fetch transactions for + * @param endpoint - The base endpoint (e.g., 'addresses/xyz' or 'accounts/stake123') * @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; - } + private async fetchTransactionsWithPagination( + endpoint: string, + options: FetchTransactionsOptions ): Promise { const limit = options.limit ?? DB_MAX_SAFE_INTEGER; @@ -822,7 +834,7 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement paginationOptions: options.pagination, request: (paginationQueryString) => { const queryString = this.buildTransactionQueryString( - `addresses/${address}/transactions`, + `${endpoint}/transactions`, paginationQueryString, options.blockRange ); @@ -832,79 +844,47 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement ); } + /** + * 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 + * @returns Promise resolving to array of transaction contents + */ + private async fetchTransactionsByAddress( + address: Cardano.PaymentAddress, + options: FetchTransactionsOptions + ): Promise { + return this.fetchTransactionsWithPagination(`addresses/${address}`, options); + } + /** * 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 + * @param options - Options for pagination, block range, and limit * @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 }; - } + options: FetchTransactionsOptions ): 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); - } - } - ); + return this.fetchTransactionsWithPagination(`addresses/${credential}`, options); } /** * 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 + * @param options - Options for pagination, block range, and limit * @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 }; - } + options: FetchTransactionsOptions ): 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); - } - } - ); + // Note: accounts/{stake_address}/transactions returns the same structure as address_transactions_content + return this.fetchTransactionsWithPagination(`accounts/${rewardAccount}`, options); } /** diff --git a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts index 3d95ef03b5c..6984d66d254 100644 --- a/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts +++ b/packages/cardano-services-client/test/ChainHistoryProvider/BlockfrostChainHistoryProvider.test.ts @@ -1073,6 +1073,68 @@ describe('blockfrostChainHistoryProvider', () => { expect(result.pageResults.length).toBe(0); expect(result.totalResultCount).toBe(0); }); + + test('respects pagination limit and avoids fetching all pages', async () => { + // Create 300 transactions (3 pages with default page size of 100) + const allTransactions = Array.from({ length: 300 }, (_, i) => ({ + block_height: 100 + i, + block_time: 1_000_000 + i * 1000, + tx_hash: `${i.toString().padStart(4, '0')}3f100dce12d107f679685acd2fc0610e10f72a92d412794c9773d11d8477`, + tx_index: 0 + })); + + // Create mock responses for all 300 transactions (to handle the bug where it fetches all) + const txResponses: Record = {}; + for (const transaction of allTransactions) { + txResponses[transaction.tx_hash] = { + ...mockedTx1Response, + block_height: transaction.block_height, + hash: transaction.tx_hash + }; + } + + // Track pagination requests + const paginationRequests: string[] = []; + + const handleCredentialRequest = (url: string) => { + paginationRequests.push(url); + + // Parse page number from query string (page size is always 100) + const pageMatch = url.match(/[&?]page=(\d+)/); + const page = pageMatch ? Number.parseInt(pageMatch[1], 10) : 1; + const pageSize = 100; + + // Return transactions for this page + const startIdx = (page - 1) * pageSize; + const endIdx = Math.min(startIdx + pageSize, allTransactions.length); + return Promise.resolve(allTransactions.slice(startIdx, endIdx)); + }; + + request.mockImplementation( + createMockRequestHandler(txResponses, txsUtxosResponse, (url) => { + if (url.includes(ADDR_VKH_PREFIX) && url.includes(TRANSACTIONS_PATH)) { + return handleCredentialRequest(url); + } + return null; + }) + ); + + // Request 200 transactions out of 300 total (exactly 2 pages) + const result = await provider.transactionsByAddresses({ + addresses: [baseAddress1], + pagination: { limit: 200, startAt: 0 } + }); + + // Should return exactly 200 transactions + expect(result.pageResults.length).toBe(200); + + // Should fetch exactly 2 pages to get 200 transactions (not all 3 pages) + expect(paginationRequests.length).toBe(2); + + // Verify the pagination requests + expect(paginationRequests[0]).toContain('page=1'); + expect(paginationRequests[1]).toContain('page=2'); + }); }); }); }); From beae9422b9dee7fd87cc8fce214a4e3b4a5c8c61 Mon Sep 17 00:00:00 2001 From: Martynas Kazlauskas Date: Fri, 7 Nov 2025 15:06:38 +0100 Subject: [PATCH 2/2] refactor(cardano-services-client): remove redundant haveEnoughItems from UTXO queries The haveEnoughItems parameter with value () => false is redundant in fetchUtxosByPaymentCredential and fetchUtxosByRewardAccount. When not specified, fetchSequentially defaults to fetching all pages, which is the correct behavior for UTXO queries (they should return all UTXOs). This aligns credential-based UTXO queries with the by-address reference implementation which also omits haveEnoughItems. --- .../src/UtxoProvider/BlockfrostUtxoProvider.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts index 28e697147db..90ad34ce1e2 100644 --- a/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts +++ b/packages/cardano-services-client/src/UtxoProvider/BlockfrostUtxoProvider.ts @@ -72,7 +72,6 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr 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); @@ -90,7 +89,6 @@ export class BlockfrostUtxoProvider extends BlockfrostProvider implements UtxoPr 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);