Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ export const DB_MAX_SAFE_INTEGER = 2_147_483_647;
type BlockfrostTx = Pick<Responses['address_transactions_content'][0], 'block_height' | 'tx_index'>;
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;
}
Expand Down Expand Up @@ -610,11 +632,12 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement
private async fetchAllByPaymentCredentials(
credentials: Map<Cardano.PaymentCredential, Cardano.PaymentAddress[]>,
pagination?: { count: number; order?: 'asc' | 'desc'; page: number },
blockRange?: { lowerBound?: number; upperBound?: number }
blockRange?: { lowerBound?: number; upperBound?: number },
limit?: number
): Promise<BlockfrostTransactionContent[]> {
const results = await Promise.all(
[...credentials.keys()].map((credential) =>
this.fetchTransactionsByPaymentCredential(credential, { blockRange, pagination })
this.fetchTransactionsByPaymentCredential(credential, { blockRange, limit, pagination })
)
);
return results.flat();
Expand All @@ -624,11 +647,12 @@ export class BlockfrostChainHistoryProvider extends BlockfrostProvider implement
private async fetchAllByRewardAccounts(
rewardAccounts: Map<Cardano.RewardAccount, Cardano.PaymentAddress[]>,
pagination?: { count: number; order?: 'asc' | 'desc'; page: number },
blockRange?: { lowerBound?: number; upperBound?: number }
blockRange?: { lowerBound?: number; upperBound?: number },
limit?: number
): Promise<BlockfrostTransactionContent[]> {
const results = await Promise.all(
[...rewardAccounts.keys()].map((rewardAccount) =>
this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, pagination })
this.fetchTransactionsByRewardAccount(rewardAccount, { blockRange, limit, pagination })
)
);
return results.flat();
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
]);

Expand Down Expand Up @@ -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<BlockfrostTransactionContent[]> {
const limit = options.limit ?? DB_MAX_SAFE_INTEGER;

Expand All @@ -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
);
Expand All @@ -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<BlockfrostTransactionContent[]> {
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<BlockfrostTransactionContent[]> {
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<Responses['address_transactions_content']>(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<BlockfrostTransactionContent[]> {
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<Responses['address_transactions_content']>(queryString);
}
}
);
// Note: accounts/{stake_address}/transactions returns the same structure as address_transactions_content
return this.fetchTransactionsWithPagination(`accounts/${rewardAccount}`, options);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Responses['address_utxo_content']>(queryString);
Expand All @@ -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<Responses['address_utxo_content']>(queryString);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {};
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');
});
});
});
});
Loading