Skip to content

Commit ec9bbff

Browse files
committed
feat(lightning): add cursor-based pagination for transaction listing
Ticket: BTC-2566
1 parent aa2ad8f commit ec9bbff

File tree

4 files changed

+243
-10
lines changed

4 files changed

+243
-10
lines changed

examples/ts/btc/lightning/list-transactions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ async function main(): Promise<void> {
6666
if (endDate) queryParams.endDate = endDate;
6767

6868
// List transactions with the provided filters
69-
const transactions = await lightning.listTransactions(queryParams);
69+
const { transactions } = await lightning.listTransactions(queryParams);
7070

7171
// Display transaction summary
7272
console.log(`\nFound ${transactions.length} transactions:`);

modules/abstract-lightning/src/codecs/api/transaction.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,38 @@ export const Transaction = t.intersection(
7373
);
7474
export type Transaction = t.TypeOf<typeof Transaction>;
7575

76+
export const ListTransactionsResponse = t.intersection(
77+
[
78+
t.type({
79+
transactions: t.array(Transaction),
80+
}),
81+
t.partial({
82+
/**
83+
* This is the transactionId of the last Transaction in the last iteration.
84+
* Providing this value as the prevId in the next request will return the next batch of transactions.
85+
* */
86+
nextBatchPrevId: t.string,
87+
}),
88+
],
89+
'ListTransactionsResponse'
90+
);
91+
export type ListTransactionsResponse = t.TypeOf<typeof ListTransactionsResponse>;
92+
7693
/**
77-
* Transaction query parameters
94+
* Transaction query parameters for listing transactions with cursor-based pagination
7895
*/
7996
export const TransactionQuery = t.partial(
8097
{
81-
blockHeight: BigIntFromString,
98+
/** Maximum number of transactions to return per page */
8299
limit: BigIntFromString,
100+
/** Optional filter for transactions at a specific block height */
101+
blockHeight: BigIntFromString,
102+
/** Optional start date filter */
83103
startDate: DateFromISOString,
104+
/** Optional end date filter */
84105
endDate: DateFromISOString,
106+
/** Transaction ID provided by nextBatchPrevId in the previous list (cursor for pagination) */
107+
prevId: t.string,
85108
},
86109
'TransactionQuery'
87110
);

modules/abstract-lightning/src/wallet/lightning.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
LightningOnchainWithdrawResponse,
3333
ListInvoicesResponse,
3434
ListPaymentsResponse,
35+
ListTransactionsResponse,
3536
LndCreateWithdrawResponse,
3637
WatchOnly,
3738
} from '../codecs';
@@ -207,14 +208,16 @@ export interface ILightningWallet {
207208
getTransaction(txId: string): Promise<Transaction>;
208209

209210
/**
210-
* List transactions for a wallet with optional filtering
211-
* @param {TransactionQuery} params Query parameters for filtering transactions
212-
* @param {bigint} [params.limit] The maximum number of transactions to return
211+
* List transactions for a wallet with optional filtering and cursor-based pagination
212+
* @param {TransactionQuery} params Query parameters for filtering and pagination
213+
* @param {bigint} [params.limit] The maximum number of transactions to return per page
214+
* @param {bigint} [params.blockHeight] Optional filter for transactions at a specific block height
213215
* @param {Date} [params.startDate] The start date for the query
214216
* @param {Date} [params.endDate] The end date for the query
215-
* @returns {Promise<Transaction[]>} List of transactions
217+
* @param {string} [params.prevId] Continue iterating from this transaction ID (provided by nextBatchPrevId in the previous list)
218+
* @returns {Promise<ListTransactionsResponse>} List of transactions and nextBatchPrevId for pagination
216219
*/
217-
listTransactions(params: TransactionQuery): Promise<Transaction[]>;
220+
listTransactions(params: TransactionQuery): Promise<ListTransactionsResponse>;
218221
}
219222

220223
export class LightningWallet implements ILightningWallet {
@@ -498,12 +501,12 @@ export class LightningWallet implements ILightningWallet {
498501
});
499502
}
500503

501-
async listTransactions(params: TransactionQuery): Promise<Transaction[]> {
504+
async listTransactions(params: TransactionQuery): Promise<ListTransactionsResponse> {
502505
const response = await this.wallet.bitgo
503506
.get(this.wallet.bitgo.url(`/wallet/${this.wallet.id()}/lightning/transaction`, 2))
504507
.query(TransactionQuery.encode(params))
505508
.result();
506-
return decodeOrElse(t.array(Transaction).name, t.array(Transaction), response, (error) => {
509+
return decodeOrElse(ListTransactionsResponse.name, ListTransactionsResponse, response, (error) => {
507510
throw new Error(`Invalid transaction list response: ${error}`);
508511
});
509512
}

modules/bitgo/test/v2/unit/lightning/lightningWallets.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
LightningOnchainWithdrawParams,
1818
PaymentInfo,
1919
PaymentQuery,
20+
TransactionQuery,
2021
} from '@bitgo/abstract-lightning';
2122

2223
import { BitGo, common, GenerateLightningWalletOptions, Wallet, Wallets } from '../../../../src';
@@ -616,6 +617,212 @@ describe('Lightning wallets', function () {
616617
});
617618
});
618619

620+
describe('Lightning Transactions', function () {
621+
let wallet: LightningWallet;
622+
623+
beforeEach(function () {
624+
wallet = getLightningWallet(
625+
new Wallet(bitgo, basecoin, {
626+
id: 'walletId',
627+
coin: 'tlnbtc',
628+
subType: 'lightningCustody',
629+
coinSpecific: { keys: ['def', 'ghi'] },
630+
})
631+
) as LightningWallet;
632+
});
633+
it('should list transactions', async function () {
634+
const transaction = {
635+
id: 'tx123',
636+
normalizedTxHash: 'normalizedHash123',
637+
blockHeight: 100000,
638+
inputIds: ['input1', 'input2'],
639+
entries: [
640+
{
641+
inputs: 1,
642+
outputs: 2,
643+
value: 50000,
644+
valueString: '50000',
645+
address: 'testAddress',
646+
wallet: wallet.wallet.id(),
647+
},
648+
],
649+
inputs: [
650+
{
651+
id: 'input1',
652+
value: 50000,
653+
valueString: '50000',
654+
address: 'inputAddress',
655+
wallet: wallet.wallet.id(),
656+
},
657+
],
658+
outputs: [
659+
{
660+
id: 'output1',
661+
value: 49500,
662+
valueString: '49500',
663+
address: 'outputAddress',
664+
wallet: wallet.wallet.id(),
665+
},
666+
],
667+
size: 250,
668+
date: new Date('2023-01-01T00:00:00Z'),
669+
fee: 500,
670+
feeString: '500',
671+
hex: 'deadbeef',
672+
confirmations: 6,
673+
};
674+
const query = {
675+
limit: 100n,
676+
startDate: new Date(),
677+
};
678+
const listTransactionsNock = nock(bgUrl)
679+
.get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`)
680+
.query(TransactionQuery.encode(query))
681+
.reply(200, { transactions: [transaction] });
682+
const listTransactionsResponse = await wallet.listTransactions(query);
683+
assert.strictEqual(listTransactionsResponse.transactions.length, 1);
684+
assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction);
685+
assert.strictEqual(listTransactionsResponse.nextBatchPrevId, undefined);
686+
listTransactionsNock.done();
687+
});
688+
689+
it('should work properly with pagination while listing transactions', async function () {
690+
const transaction1 = {
691+
id: 'tx123',
692+
normalizedTxHash: 'normalizedHash123',
693+
blockHeight: 100000,
694+
inputIds: ['input1', 'input2'],
695+
entries: [
696+
{
697+
inputs: 1,
698+
outputs: 2,
699+
value: 50000,
700+
valueString: '50000',
701+
address: 'testAddress',
702+
wallet: wallet.wallet.id(),
703+
},
704+
],
705+
inputs: [
706+
{
707+
id: 'input1',
708+
value: 50000,
709+
valueString: '50000',
710+
address: 'inputAddress',
711+
wallet: wallet.wallet.id(),
712+
},
713+
],
714+
outputs: [
715+
{
716+
id: 'output1',
717+
value: 49500,
718+
valueString: '49500',
719+
address: 'outputAddress',
720+
wallet: wallet.wallet.id(),
721+
},
722+
],
723+
size: 250,
724+
date: new Date('2023-01-01T00:00:00Z'),
725+
fee: 500,
726+
feeString: '500',
727+
hex: 'deadbeef',
728+
confirmations: 6,
729+
};
730+
const transaction2 = {
731+
...transaction1,
732+
id: 'tx456',
733+
normalizedTxHash: 'normalizedHash456',
734+
blockHeight: 100001,
735+
date: new Date('2023-01-02T00:00:00Z'),
736+
};
737+
const transaction3 = {
738+
...transaction1,
739+
id: 'tx789',
740+
normalizedTxHash: 'normalizedHash789',
741+
blockHeight: 100002,
742+
date: new Date('2023-01-03T00:00:00Z'),
743+
};
744+
const allTransactions = [transaction1, transaction2, transaction3];
745+
const query = {
746+
limit: 2n,
747+
startDate: new Date('2023-01-01'),
748+
};
749+
const listTransactionsNock = nock(bgUrl)
750+
.get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`)
751+
.query(TransactionQuery.encode(query))
752+
.reply(200, { transactions: allTransactions.slice(0, 2), nextBatchPrevId: transaction2.id });
753+
const listTransactionsResponse = await wallet.listTransactions(query);
754+
assert.strictEqual(listTransactionsResponse.transactions.length, 2);
755+
assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction1);
756+
assert.deepStrictEqual(listTransactionsResponse.transactions[1], transaction2);
757+
assert.strictEqual(listTransactionsResponse.nextBatchPrevId, transaction2.id);
758+
listTransactionsNock.done();
759+
});
760+
761+
it('should handle prevId parameter for pagination cursor', async function () {
762+
const transaction3 = {
763+
id: 'tx789',
764+
normalizedTxHash: 'normalizedHash789',
765+
blockHeight: 100002,
766+
inputIds: ['input1'],
767+
entries: [
768+
{
769+
inputs: 1,
770+
outputs: 1,
771+
value: 40000,
772+
valueString: '40000',
773+
address: 'testAddress',
774+
wallet: wallet.wallet.id(),
775+
},
776+
],
777+
inputs: [
778+
{
779+
id: 'input1',
780+
value: 40000,
781+
valueString: '40000',
782+
address: 'inputAddress',
783+
wallet: wallet.wallet.id(),
784+
},
785+
],
786+
outputs: [
787+
{
788+
id: 'output1',
789+
value: 39500,
790+
valueString: '39500',
791+
address: 'outputAddress',
792+
wallet: wallet.wallet.id(),
793+
},
794+
],
795+
size: 200,
796+
date: new Date('2023-01-03T00:00:00Z'),
797+
fee: 500,
798+
feeString: '500',
799+
hex: 'cafebabe',
800+
confirmations: 4,
801+
};
802+
const query = {
803+
limit: 1n,
804+
prevId: 'tx456', // Continue from this transaction ID
805+
};
806+
const listTransactionsNock = nock(bgUrl)
807+
.get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`)
808+
.query(TransactionQuery.encode(query))
809+
.reply(200, { transactions: [transaction3] });
810+
const listTransactionsResponse = await wallet.listTransactions(query);
811+
assert.strictEqual(listTransactionsResponse.transactions.length, 1);
812+
assert.deepStrictEqual(listTransactionsResponse.transactions[0], transaction3);
813+
assert.strictEqual(listTransactionsResponse.nextBatchPrevId, undefined);
814+
listTransactionsNock.done();
815+
});
816+
817+
it('listTransactions should throw error if response is invalid', async function () {
818+
const listTransactionsNock = nock(bgUrl)
819+
.get(`/api/v2/wallet/${wallet.wallet.id()}/lightning/transaction`)
820+
.reply(200, { transactions: [{ id: 'invalid' }] });
821+
await assert.rejects(async () => await wallet.listTransactions({}), /Invalid transaction list response/);
822+
listTransactionsNock.done();
823+
});
824+
});
825+
619826
describe('Get lightning key(s)', function () {
620827
const walletData = {
621828
id: 'fakeid',

0 commit comments

Comments
 (0)