From ce9b7353149ff67909b94813979949b47303372c Mon Sep 17 00:00:00 2001 From: andyyan162 Date: Mon, 29 Sep 2025 14:31:22 -0400 Subject: [PATCH] feat(abstract-lightning): add cursor-based pagination for lightning transactions Ticket: BTC-2566 - Add prevId parameter to TransactionQuery for cursor-based pagination - Add nextBatchPrevId to ListTransactionsResponse for pagination continuation - Update lightning wallet interface to return paginated response format - Add comprehensive JSDoc documentation for pagination cursor pattern - Update example to use new destructured response format - Add comprehensive test coverage for pagination scenarios --- .../ts/btc/lightning/list-transactions.ts | 2 +- .../src/codecs/api/transaction.ts | 27 ++- .../src/wallet/lightning.ts | 12 +- .../v2/unit/lightning/lightningWallets.ts | 192 ++++++++++++++++++ 4 files changed, 225 insertions(+), 8 deletions(-) diff --git a/examples/ts/btc/lightning/list-transactions.ts b/examples/ts/btc/lightning/list-transactions.ts index e1a61730d4..230e446856 100644 --- a/examples/ts/btc/lightning/list-transactions.ts +++ b/examples/ts/btc/lightning/list-transactions.ts @@ -66,7 +66,7 @@ async function main(): Promise { if (endDate) queryParams.endDate = endDate; // List transactions with the provided filters - const transactions = await lightning.listTransactions(queryParams); + const { transactions } = await lightning.listTransactions(queryParams); // Display transaction summary console.log(`\nFound ${transactions.length} transactions:`); diff --git a/modules/abstract-lightning/src/codecs/api/transaction.ts b/modules/abstract-lightning/src/codecs/api/transaction.ts index 4fbed5f31f..2701331dee 100644 --- a/modules/abstract-lightning/src/codecs/api/transaction.ts +++ b/modules/abstract-lightning/src/codecs/api/transaction.ts @@ -73,15 +73,38 @@ export const Transaction = t.intersection( ); export type Transaction = t.TypeOf; +export const ListTransactionsResponse = t.intersection( + [ + t.type({ + transactions: t.array(Transaction), + }), + t.partial({ + /** + * Transaction ID of the last transaction in this batch. + * Use as prevId in next request to continue pagination. + */ + nextBatchPrevId: t.string, + }), + ], + 'ListTransactionsResponse' +); +export type ListTransactionsResponse = t.TypeOf; + /** - * Transaction query parameters + * Transaction query parameters with cursor-based pagination */ export const TransactionQuery = t.partial( { - blockHeight: BigIntFromString, + /** Maximum number of transactions to return per page */ limit: BigIntFromString, + /** Optional filter for transactions at a specific block height */ + blockHeight: BigIntFromString, + /** Optional start date filter */ startDate: DateFromISOString, + /** Optional end date filter */ endDate: DateFromISOString, + /** Transaction ID for cursor-based pagination (from nextBatchPrevId) */ + prevId: t.string, }, 'TransactionQuery' ); diff --git a/modules/abstract-lightning/src/wallet/lightning.ts b/modules/abstract-lightning/src/wallet/lightning.ts index d2ff8bc76c..0ce42b7fc4 100644 --- a/modules/abstract-lightning/src/wallet/lightning.ts +++ b/modules/abstract-lightning/src/wallet/lightning.ts @@ -26,6 +26,7 @@ import { SubmitPaymentParams, Transaction, TransactionQuery, + ListTransactionsResponse, PaymentInfo, PaymentQuery, LightningOnchainWithdrawParams, @@ -199,14 +200,15 @@ export interface ILightningWallet { getTransaction(txId: string): Promise; /** - * List transactions for a wallet with optional filtering + * List transactions for a wallet with optional filtering and cursor-based pagination * @param {TransactionQuery} params Query parameters for filtering transactions * @param {bigint} [params.limit] The maximum number of transactions to return * @param {Date} [params.startDate] The start date for the query * @param {Date} [params.endDate] The end date for the query - * @returns {Promise} List of transactions + * @param {string} [params.prevId] Transaction ID for cursor-based pagination (from nextBatchPrevId) + * @returns {Promise} List of transactions with pagination info */ - listTransactions(params: TransactionQuery): Promise; + listTransactions(params: TransactionQuery): Promise; } export class LightningWallet implements ILightningWallet { @@ -472,12 +474,12 @@ export class LightningWallet implements ILightningWallet { }); } - async listTransactions(params: TransactionQuery): Promise { + async listTransactions(params: TransactionQuery): Promise { const response = await this.wallet.bitgo .get(this.wallet.bitgo.url(`/wallet/${this.wallet.id()}/lightning/transaction`, 2)) .query(TransactionQuery.encode(params)) .result(); - return decodeOrElse(t.array(Transaction).name, t.array(Transaction), response, (error) => { + return decodeOrElse(ListTransactionsResponse.name, ListTransactionsResponse, response, (error) => { throw new Error(`Invalid transaction list response: ${error}`); }); } diff --git a/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts b/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts index c34b0ea72b..dbb1a38040 100644 --- a/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts +++ b/modules/bitgo/test/v2/unit/lightning/lightningWallets.ts @@ -17,6 +17,7 @@ import { LightningOnchainWithdrawParams, PaymentInfo, PaymentQuery, + TransactionQuery, } from '@bitgo/abstract-lightning'; import { BitGo, common, GenerateLightningWalletOptions, Wallet, Wallets } from '../../../../src'; @@ -1067,4 +1068,195 @@ describe('Lightning wallets', function () { getPendingApprovalNock.done(); }); }); + + describe('transactions', function () { + let wallet: LightningWallet; + + beforeEach(function () { + wallet = getLightningWallet( + new Wallet(bitgo, basecoin, { + id: 'walletId', + coin: 'tlnbtc', + subType: 'lightningCustody', + coinSpecific: { keys: ['def', 'ghi'] }, + }) + ) as LightningWallet; + }); + + it('should list transactions', async function () { + const transaction = { + id: 'tx123', + normalizedTxHash: 'normalizedHash123', + blockHeight: 100000, + inputIds: ['input1', 'input2'], + entries: [ + { + inputs: 1, + outputs: 2, + value: 50000, + valueString: '50000', + address: 'testAddress', + wallet: wallet.wallet.id(), + }, + ], + inputs: [ + { + id: 'input1', + value: 50000, + valueString: '50000', + address: 'inputAddress', + wallet: wallet.wallet.id(), + }, + ], + outputs: [ + { + id: 'output1', + value: 49500, + valueString: '49500', + address: 'outputAddress', + wallet: wallet.wallet.id(), + }, + ], + size: 250, + date: new Date('2023-01-01T00:00:00Z'), + fee: 500, + feeString: '500', + hex: 'deadbeef', + confirmations: 6, + }; + const query = { + limit: 100n, + startDate: new Date(), + }; + const listTransactionsNock = nock(bgUrl) + .get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`) + .query(TransactionQuery.encode(query)) + .reply(200, { transactions: [transaction] }); + const listTransactionsResponse = await wallet.listTransactions(query); + assert.strictEqual(listTransactionsResponse.transactions.length, 1); + assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction); + assert.strictEqual(listTransactionsResponse.nextBatchPrevId, undefined); + listTransactionsNock.done(); + }); + + it('should work properly with pagination while listing transactions', async function () { + const transaction1 = { + id: 'tx123', + normalizedTxHash: 'normalizedHash123', + blockHeight: 100000, + inputIds: ['input1', 'input2'], + entries: [ + { + inputs: 1, + outputs: 2, + value: 50000, + valueString: '50000', + address: 'testAddress', + wallet: wallet.wallet.id(), + }, + ], + inputs: [ + { + id: 'input1', + value: 50000, + valueString: '50000', + address: 'inputAddress', + wallet: wallet.wallet.id(), + }, + ], + outputs: [ + { + id: 'output1', + value: 49500, + valueString: '49500', + address: 'outputAddress', + wallet: wallet.wallet.id(), + }, + ], + size: 250, + date: new Date('2023-01-01T00:00:00Z'), + fee: 500, + feeString: '500', + hex: 'deadbeef', + confirmations: 6, + }; + const transaction2 = { + ...transaction1, + id: 'tx456', + normalizedTxHash: 'normalizedHash456', + blockHeight: 100001, + date: new Date('2023-01-02T00:00:00Z'), + }; + const query = { + limit: 2n, + startDate: new Date('2023-01-01'), + }; + const listTransactionsNock = nock(bgUrl) + .get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`) + .query(TransactionQuery.encode(query)) + .reply(200, { transactions: [transaction1, transaction2], nextBatchPrevId: transaction2.id }); + const listTransactionsResponse = await wallet.listTransactions(query); + assert.strictEqual(listTransactionsResponse.transactions.length, 2); + assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction1); + assert.deepStrictEqual(listTransactionsResponse.transactions[1], transaction2); + assert.strictEqual(listTransactionsResponse.nextBatchPrevId, transaction2.id); + listTransactionsNock.done(); + }); + + it('should handle prevId parameter for pagination cursor', async function () { + const transaction3 = { + id: 'tx789', + normalizedTxHash: 'normalizedHash789', + blockHeight: 100002, + inputIds: ['input1'], + entries: [ + { + inputs: 1, + outputs: 1, + value: 40000, + valueString: '40000', + address: 'testAddress', + wallet: wallet.wallet.id(), + }, + ], + inputs: [ + { + id: 'input1', + value: 40000, + valueString: '40000', + address: 'inputAddress', + wallet: wallet.wallet.id(), + }, + ], + outputs: [ + { + id: 'output1', + value: 39500, + valueString: '39500', + address: 'outputAddress', + wallet: wallet.wallet.id(), + }, + ], + size: 200, + date: new Date('2023-01-03T00:00:00Z'), + fee: 500, + feeString: '500', + hex: 'cafebabe', + confirmations: 4, + }; + const query = { + limit: 1n, + prevId: 'tx456', // Continue from this transaction ID + }; + const listTransactionsNock = nock(bgUrl) + .get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`) + .query(TransactionQuery.encode(query)) + .reply(200, { transactions: [transaction3] }); + const listTransactionsResponse = await wallet.listTransactions(query); + assert.strictEqual(listTransactionsResponse.transactions.length, 1); + assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction3); + assert.strictEqual(listTransactionsResponse.nextBatchPrevId, undefined); + listTransactionsNock.done(); + }); + }); });