From 650eec2c25c85f8fa73be9672189b5e6c47fb267 Mon Sep 17 00:00:00 2001 From: saraogiraj94 Date: Thu, 14 May 2026 16:29:16 +0530 Subject: [PATCH 1/2] lnwallet+rpc: add label filter to GetTransactions/ListTransactionDetails Adds an optional `label` string field to `GetTransactionsRequest` (proto field 6) so callers can filter chain transactions by label. - `WalletController.ListTransactionDetails` gains a `labelFilter string` parameter; when non-empty, only transactions whose `Label` field exactly matches are returned (client-side filter after the full fetch). - `rpcserver.GetTransactions` threads `req.Label` through to the wallet call. - `lncli listchaintxns` gains a `--label` flag. - All mocks and the integration-test harness are updated to pass the new parameter. - New integration test `testListTransactionDetailsLabelFilter` covers: exact match by label, no-filter returns all, and unknown label returns empty. Co-Authored-By: Claude Sonnet 4.6 --- cmd/commands/commands.go | 7 + docs/release-notes/release-notes-0.22.0.md | 9 ++ lnrpc/lightning.pb.go | 12 +- lnrpc/lightning.proto | 3 + lnrpc/lightning.swagger.json | 7 + lntest/mock/walletcontroller.go | 2 +- lnwallet/btcwallet/btcwallet.go | 14 +- lnwallet/interface.go | 6 +- lnwallet/mock.go | 3 +- lnwallet/test/test_interface.go | 158 +++++++++++++++++++-- rpcserver.go | 2 +- 11 files changed, 204 insertions(+), 19 deletions(-) diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index 0c389a610b0..ff7adb80f7f 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -2288,6 +2288,12 @@ var listChainTxnsCommand = cli.Command{ "all transactions", Value: 0, }, + cli.StringFlag{ + Name: "label", + Usage: "an optional label to filter " + + "transactions by; only transactions with " + + "a matching label will be returned", + }, }, Description: ` List all transactions an address of the wallet was involved in. @@ -2338,6 +2344,7 @@ func listChainTxns(ctx *cli.Context) error { MaxTransactions: uint32(ctx.Uint64("max_transactions")), StartHeight: startHeight, EndHeight: endHeight, + Label: ctx.String("label"), } resp, err := client.GetTransactions(ctxc, req) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 45e76051922..5ea2c45cb95 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -45,6 +45,11 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `outgoing_chan_ids` field in `RouteFeeRequest`. +* [`GetTransactions`](https://github.com/lightningnetwork/lnd/pull/10806) + now accepts an optional `label` field to filter returned transactions by + their label. Only transactions whose label exactly matches the provided + value are returned; omitting the field returns all transactions as before. + ## lncli Additions * The `estimateroutefee` command now supports [restricting fee estimates to @@ -52,6 +57,9 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `--outgoing_chan_id` flag. +* `listchaintxns` now accepts an optional `--label` flag to filter returned + transactions by their label. + # Improvements ## Functional Updates @@ -89,3 +97,4 @@ * Boris Nagaev * Erick Cestari +* saraogiraj94 diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index ebfed318025..c62d4b83490 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -2376,6 +2376,8 @@ type GetTransactionsRequest struct { // The maximal number of transactions returned in the response to this query. // This value should be set to 0 to return all transactions. MaxTransactions uint32 `protobuf:"varint,5,opt,name=max_transactions,json=maxTransactions,proto3" json:"max_transactions,omitempty"` + // An optional filter to only include transactions with a matching label. + Label string `protobuf:"bytes,6,opt,name=label,proto3" json:"label,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2445,6 +2447,13 @@ func (x *GetTransactionsRequest) GetMaxTransactions() uint32 { return 0 } +func (x *GetTransactionsRequest) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + type TransactionDetails struct { state protoimpl.MessageState `protogen:"open.v1"` // The list of transactions relevant to the wallet. @@ -18573,7 +18582,8 @@ const file_lightning_proto_rawDesc = "" + "end_height\x18\x02 \x01(\x05R\tendHeight\x12\x18\n" + "\aaccount\x18\x03 \x01(\tR\aaccount\x12!\n" + "\findex_offset\x18\x04 \x01(\rR\vindexOffset\x12)\n" + - "\x10max_transactions\x18\x05 \x01(\rR\x0fmaxTransactions\"\x8c\x01\n" + + "\x10max_transactions\x18\x05 \x01(\rR\x0fmaxTransactions\x12\x14\n" + + "\x05label\x18\x06 \x01(\tR\x05label\"\x8c\x01\n" + "\x12TransactionDetails\x126\n" + "\ftransactions\x18\x01 \x03(\v2\x12.lnrpc.TransactionR\ftransactions\x12\x1d\n" + "\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index be43c88a458..c1900294f3b 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -815,6 +815,9 @@ message GetTransactionsRequest { This value should be set to 0 to return all transactions. */ uint32 max_transactions = 5; + + // An optional filter to only include transactions with a matching label. + string label = 6; } message TransactionDetails { diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 7be82853d4e..53c589f2866 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -2701,6 +2701,13 @@ "required": false, "type": "integer", "format": "int64" + }, + { + "name": "label", + "description": "An optional filter to only include transactions with a matching label.", + "in": "query", + "required": false, + "type": "string" } ], "tags": [ diff --git a/lntest/mock/walletcontroller.go b/lntest/mock/walletcontroller.go index fa623bf84dd..3faf296686d 100644 --- a/lntest/mock/walletcontroller.go +++ b/lntest/mock/walletcontroller.go @@ -187,7 +187,7 @@ func (w *WalletController) ListUnspentWitness(int32, int32, // ListTransactionDetails currently returns dummy values. func (w *WalletController) ListTransactionDetails(int32, int32, - string, uint32, uint32) ([]*lnwallet.TransactionDetail, + string, string, uint32, uint32) ([]*lnwallet.TransactionDetail, uint64, uint64, error) { return nil, 0, 0, nil diff --git a/lnwallet/btcwallet/btcwallet.go b/lnwallet/btcwallet/btcwallet.go index 354238d59de..ef448ce19a4 100644 --- a/lnwallet/btcwallet/btcwallet.go +++ b/lnwallet/btcwallet/btcwallet.go @@ -1481,7 +1481,7 @@ func unminedTransactionsToDetail( // // This is a part of the WalletController interface. func (b *BtcWallet) ListTransactionDetails(startHeight, endHeight int32, - accountFilter string, indexOffset uint32, + accountFilter string, labelFilter string, indexOffset uint32, maxTransactions uint32) ([]*lnwallet.TransactionDetail, uint64, uint64, error) { @@ -1523,6 +1523,18 @@ func (b *BtcWallet) ListTransactionDetails(startHeight, endHeight int32, txDetails = append(txDetails, detail) } + // If a label filter is specified, only include transactions whose label + // matches exactly. + if labelFilter != "" { + filtered := txDetails[:0] + for _, tx := range txDetails { + if tx.Label == labelFilter { + filtered = append(filtered, tx) + } + } + txDetails = filtered + } + // Return empty transaction list, if offset is more than all // transactions. if int(indexOffset) >= len(txDetails) { diff --git a/lnwallet/interface.go b/lnwallet/interface.go index f5a717d3f55..a7c62ffb77f 100644 --- a/lnwallet/interface.go +++ b/lnwallet/interface.go @@ -400,9 +400,11 @@ type WalletController interface { // the tip of the chain until the start height (inclusive) and // unconfirmed transactions. The account parameter serves as a filter to // retrieve the transactions relevant to a specific account. When - // empty, transactions of all wallet accounts are returned. + // empty, transactions of all wallet accounts are returned. The label + // parameter serves as an optional filter to retrieve only transactions + // with a matching label. When empty, all transactions are returned. ListTransactionDetails(startHeight, endHeight int32, - accountFilter string, indexOffset uint32, + accountFilter string, labelFilter string, indexOffset uint32, maxTransactions uint32) ([]*TransactionDetail, uint64, uint64, error) diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 39e520d2760..22dec979519 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -198,7 +198,8 @@ func (w *mockWalletController) ListUnspentWitness(int32, int32, // ListTransactionDetails currently returns dummy values. func (w *mockWalletController) ListTransactionDetails(int32, int32, - string, uint32, uint32) ([]*TransactionDetail, uint64, uint64, error) { + string, string, uint32, uint32) ([]*TransactionDetail, uint64, uint64, + error) { return nil, 0, 0, nil } diff --git a/lnwallet/test/test_interface.go b/lnwallet/test/test_interface.go index 8d180ca1006..76a5293db05 100644 --- a/lnwallet/test/test_interface.go +++ b/lnwallet/test/test_interface.go @@ -201,7 +201,7 @@ func assertTxInWallet(t *testing.T, w *lnwallet.LightningWallet, // finding the expected transaction with its expected confirmation // status. txs, _, _, err := w.ListTransactionDetails( - 0, btcwallet.UnconfirmedHeight, "", 0, 1000, + 0, btcwallet.UnconfirmedHeight, "", "", 0, 1000, ) require.NoError(t, err, "unable to retrieve transactions") for _, tx := range txs { @@ -1101,7 +1101,7 @@ func testListTransactionDetails(miner *rpctest.Harness, err = waitForWalletSync(miner, alice) require.NoError(t, err, "Couldn't sync Alice's wallet") txDetails, _, _, err := alice.ListTransactionDetails( - startHeight, chainTip, "", 0, 1000, + startHeight, chainTip, "", "", 0, 1000, ) require.NoError(t, err, "unable to fetch tx details") @@ -1213,7 +1213,7 @@ func testListTransactionDetails(miner *rpctest.Harness, // with a confirmation height of 0, indicating that it has not been // mined yet. txDetails, _, _, err = alice.ListTransactionDetails( - chainTip, btcwallet.UnconfirmedHeight, "", 0, 1000, + chainTip, btcwallet.UnconfirmedHeight, "", "", 0, 1000, ) require.NoError(t, err, "unable to fetch tx details") var mempoolTxFound bool @@ -1266,7 +1266,7 @@ func testListTransactionDetails(miner *rpctest.Harness, err = waitForWalletSync(miner, alice) require.NoError(t, err, "Couldn't sync Alice's wallet") txDetails, _, _, err = alice.ListTransactionDetails( - chainTip, chainTip, "", 0, 1000, + chainTip, chainTip, "", "", 0, 1000, ) require.NoError(t, err, "unable to fetch tx details") var burnTxFound bool @@ -1309,7 +1309,7 @@ func testListTransactionDetails(miner *rpctest.Harness, // Query for transactions only in the latest block. We do not expect // any transactions to be returned. txDetails, _, _, err = alice.ListTransactionDetails( - chainTip, chainTip, "", 0, 1000, + chainTip, chainTip, "", "", 0, 1000, ) require.NoError(t, err, "unexpected error") if len(txDetails) != 0 { @@ -1364,7 +1364,7 @@ func testListTransactionDetailsOffset(miner *rpctest.Harness, // Query for transactions, setting max_transactions to 5. We expect 5 // transactions to be returned. txDetails, firstIdx, lastIdx, err := alice.ListTransactionDetails( - startHeight, chainTip, "", 0, 5, + startHeight, chainTip, "", "", 0, 5, ) require.NoError(t, err) require.Len(t, txDetails, 5) @@ -1374,7 +1374,7 @@ func testListTransactionDetailsOffset(miner *rpctest.Harness, // Query for transactions, setting max_transactions to less than the // number of transactions we have (5). txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 0, 1, + startHeight, chainTip, "", "", 0, 1, ) require.NoError(t, err) require.Len(t, txDetails, 1) @@ -1382,7 +1382,7 @@ func testListTransactionDetailsOffset(miner *rpctest.Harness, // Query for transactions, setting indexOffset to 5 (equal to number // of transactions we have) and max_transactions to 0. txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 5, 0, + startHeight, chainTip, "", "", 5, 0, ) require.NoError(t, err) require.Len(t, txDetails, 0) @@ -1390,21 +1390,21 @@ func testListTransactionDetailsOffset(miner *rpctest.Harness, // Query for transactions, setting indexOffset to 4 (edge offset) and // max_transactions to 0. txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 4, 0, + startHeight, chainTip, "", "", 4, 0, ) require.NoError(t, err) require.Len(t, txDetails, 1) // Query for transactions, setting max_transactions to 0. txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 0, 0, + startHeight, chainTip, "", "", 0, 0, ) require.NoError(t, err) require.Len(t, txDetails, 5) // Query for transactions, more than we have in the wallet (5). txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 0, 10, + startHeight, chainTip, "", "", 0, 10, ) require.NoError(t, err) require.Len(t, txDetails, 5) @@ -1412,12 +1412,142 @@ func testListTransactionDetailsOffset(miner *rpctest.Harness, // Query for transactions where the offset is greater than the number // of transactions available. txDetails, _, _, err = alice.ListTransactionDetails( - startHeight, chainTip, "", 10, 100, + startHeight, chainTip, "", "", 10, 100, ) require.NoError(t, err) require.Len(t, txDetails, 0) } +func testListTransactionDetailsLabelFilter(miner *rpctest.Harness, + alice, _ *lnwallet.LightningWallet, t *testing.T) { + + const outputAmt = btcutil.SatoshiPerBitcoin + + // Fund Alice's wallet by having the miner send to several of her + // addresses. + const numFundingTxns = 4 + for i := 0; i < numFundingTxns; i++ { + addr, err := alice.NewAddress( + lnwallet.WitnessPubKey, false, + lnwallet.DefaultAccountName, + ) + require.NoError(t, err) + + script, err := txscript.PayToAddrScript(addr) + require.NoError(t, err) + + output := &wire.TxOut{ + Value: outputAmt, + PkScript: script, + } + _, err = miner.SendOutputs([]*wire.TxOut{output}, 2500) + require.NoError(t, err) + } + + // Mine blocks to confirm the funding transactions and sync the wallet. + const numFundingBlocks = 10 + _, err := miner.Client.Generate(numFundingBlocks) + require.NoError(t, err) + + err = waitForWalletSync(miner, alice) + require.NoError(t, err) + + // Record the current chain height so we only query transactions from + // this point forward, excluding the funding transactions. + _, startHeight, err := miner.Client.GetBestBlock() + require.NoError(t, err) + + // Fetch an external address from the miner to act as a recipient. + minerAddr, err := miner.NewAddress() + require.NoError(t, err) + + outputScript, err := txscript.PayToAddrScript(minerAddr) + require.NoError(t, err) + + const ( + labelA = "pay-alice" + labelB = "pay-bob" + numLabelTxns = 2 + ) + + // Send two transactions labelled with labelA. + labelATxIDs := make(map[chainhash.Hash]struct{}) + for i := 0; i < numLabelTxns; i++ { + tx, err := alice.SendOutputs( + nil, + []*wire.TxOut{{Value: outputAmt / 4, PkScript: outputScript}}, + 2500, 1, labelA, alice.Cfg.CoinSelectionStrategy, + ) + require.NoError(t, err) + txid := tx.TxHash() + labelATxIDs[txid] = struct{}{} + err = waitForMempoolTx(miner, &txid) + require.NoError(t, err) + } + + // Send two transactions labelled with labelB. + labelBTxIDs := make(map[chainhash.Hash]struct{}) + for i := 0; i < numLabelTxns; i++ { + tx, err := alice.SendOutputs( + nil, + []*wire.TxOut{{Value: outputAmt / 4, PkScript: outputScript}}, + 2500, 1, labelB, alice.Cfg.CoinSelectionStrategy, + ) + require.NoError(t, err) + txid := tx.TxHash() + labelBTxIDs[txid] = struct{}{} + err = waitForMempoolTx(miner, &txid) + require.NoError(t, err) + } + + // Mine a block to confirm all label transactions and sync. + _, err = miner.Client.Generate(1) + require.NoError(t, err) + + chainTip := startHeight + 1 + + err = waitForWalletSync(miner, alice) + require.NoError(t, err) + + // Filtering by labelA should return only the two matching transactions. + txDetails, _, _, err := alice.ListTransactionDetails( + startHeight, chainTip, "", labelA, 0, 0, + ) + require.NoError(t, err) + require.Len(t, txDetails, numLabelTxns) + for _, tx := range txDetails { + require.Equal(t, labelA, tx.Label) + _, ok := labelATxIDs[tx.Hash] + require.True(t, ok, "unexpected tx %v in labelA results", tx.Hash) + } + + // Filtering by labelB should return only the two matching transactions. + txDetails, _, _, err = alice.ListTransactionDetails( + startHeight, chainTip, "", labelB, 0, 0, + ) + require.NoError(t, err) + require.Len(t, txDetails, numLabelTxns) + for _, tx := range txDetails { + require.Equal(t, labelB, tx.Label) + _, ok := labelBTxIDs[tx.Hash] + require.True(t, ok, "unexpected tx %v in labelB results", tx.Hash) + } + + // No label filter should return all four transactions. + txDetails, _, _, err = alice.ListTransactionDetails( + startHeight, chainTip, "", "", 0, 0, + ) + require.NoError(t, err) + require.Len(t, txDetails, numLabelTxns*2) + + // A non-existent label should return no transactions. + txDetails, _, _, err = alice.ListTransactionDetails( + startHeight, chainTip, "", "no-such-label", 0, 0, + ) + require.NoError(t, err) + require.Empty(t, txDetails) +} + func testTransactionSubscriptions(miner *rpctest.Harness, alice, _ *lnwallet.LightningWallet, t *testing.T) { @@ -2920,6 +3050,10 @@ var walletTests = []walletTestCase{ name: "transaction details offset", test: testListTransactionDetailsOffset, }, + { + name: "transaction details label filter", + test: testListTransactionDetailsLabelFilter, + }, { name: "get transaction details", test: testGetTransactionDetails, diff --git a/rpcserver.go b/rpcserver.go index 8b40192db84..55bc74ffca3 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -6188,7 +6188,7 @@ func (r *rpcServer) GetTransactions(ctx context.Context, txns, firstIdx, lastIdx, err := r.server.cc.Wallet.ListTransactionDetails( req.StartHeight, endHeight, req.Account, - req.IndexOffset, req.MaxTransactions, + req.Label, req.IndexOffset, req.MaxTransactions, ) if err != nil { return nil, err From bb9436e6ee60323d35ff8238438f0a141bccca20 Mon Sep 17 00:00:00 2001 From: saraogiraj94 Date: Mon, 1 Jun 2026 17:56:57 +0530 Subject: [PATCH 2/2] lnwallet/test: add pagination+label filter test assertions All existing label filter test calls passed maxTransactions=0, which bypasses the pagination code path entirely. Add two assertions that combine a label filter with a non-zero maxTransactions and indexOffset to verify pagination operates on the already-filtered result set. Co-Authored-By: Claude Sonnet 4.6 --- lnwallet/test/test_interface.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lnwallet/test/test_interface.go b/lnwallet/test/test_interface.go index 76a5293db05..498e6af2a1f 100644 --- a/lnwallet/test/test_interface.go +++ b/lnwallet/test/test_interface.go @@ -1546,6 +1546,31 @@ func testListTransactionDetailsLabelFilter(miner *rpctest.Harness, ) require.NoError(t, err) require.Empty(t, txDetails) + + // Pagination should operate on the already-filtered set. With + // maxTransactions=1 and labelA, only the first matching transaction + // should be returned. + txDetails, _, _, err = alice.ListTransactionDetails( + startHeight, chainTip, "", labelA, 0, 1, + ) + require.NoError(t, err) + require.Len(t, txDetails, 1) + require.Equal(t, labelA, txDetails[0].Label) + _, ok := labelATxIDs[txDetails[0].Hash] + require.True(t, ok, "unexpected tx %v in paginated labelA results", + txDetails[0].Hash) + + // With indexOffset=1 and maxTransactions=1, the second labelA + // transaction should be returned. + txDetails, _, _, err = alice.ListTransactionDetails( + startHeight, chainTip, "", labelA, 1, 1, + ) + require.NoError(t, err) + require.Len(t, txDetails, 1) + require.Equal(t, labelA, txDetails[0].Label) + _, ok = labelATxIDs[txDetails[0].Hash] + require.True(t, ok, "unexpected tx %v in paginated labelA results", + txDetails[0].Hash) } func testTransactionSubscriptions(miner *rpctest.Harness,