From adab2d729d1b3f6d115d1abfda6023ecd0939cd9 Mon Sep 17 00:00:00 2001 From: murraystewart96 Date: Sat, 16 May 2026 16:28:13 +0100 Subject: [PATCH 1/5] lnrpc: add change_address field to SendCoinsRequest lnrpc: validating change addr in SendCoins lnd: threading custom change addr through to sendCoinsOnChain when making partial payment --- docs/release-notes/release-notes-0.22.0.md | 29 +++++++ lnrpc/lightning.pb.go | 16 +++- lnrpc/lightning.proto | 3 + lnrpc/lightning.swagger.json | 4 + rpcserver.go | 89 +++++++++++++++------- 5 files changed, 109 insertions(+), 32 deletions(-) diff --git a/docs/release-notes/release-notes-0.22.0.md b/docs/release-notes/release-notes-0.22.0.md index 45e7605192..43541efa7e 100644 --- a/docs/release-notes/release-notes-0.22.0.md +++ b/docs/release-notes/release-notes-0.22.0.md @@ -38,6 +38,14 @@ ## Functional Enhancements +* When making an on-chain send, callers can now specify a + [`change_address`](https://github.com/lightningnetwork/lnd/pull/10810) to + control where any change output is sent. This enables workflows where a user + wants to drain a specific address across multiple transactions — by pinning + the change address, every sweep transaction returns leftover funds to a + known, controlled address rather than a fresh wallet-derived one. The field + is mutually exclusive with `send_all`. + ## RPC Additions * The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates @@ -45,6 +53,18 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `outgoing_chan_ids` field in `RouteFeeRequest`. +* [`SendCoins`](https://github.com/lightningnetwork/lnd/pull/10810) now accepts + a `change_address` field on `SendCoinsRequest`. When set, change is sent to + the specified address instead of a wallet-derived one. Cannot be combined + with `send_all`. + +* [`SendMany`](https://github.com/lightningnetwork/lnd/pull/10810) now accepts + a `change_address` field on `SendManyRequest` with the same semantics. + +* [`WalletKit.SendOutputs`](https://github.com/lightningnetwork/lnd/pull/10810) + now accepts a `change_address` field on `SendOutputsRequest` with the same + semantics. + ## lncli Additions * The `estimateroutefee` command now supports [restricting fee estimates to @@ -52,6 +72,15 @@ channels](https://github.com/lightningnetwork/lnd/pull/10501) via the new `--outgoing_chan_id` flag. +* `sendcoins` now accepts a + [`--change_address`](https://github.com/lightningnetwork/lnd/pull/10810) flag + to specify a custom destination for change. Mutually exclusive with + `--sweepall`. + +* `sendmany` now accepts a + [`--change_address`](https://github.com/lightningnetwork/lnd/pull/10810) flag + with the same semantics. + # Improvements ## Functional Updates diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index ebfed31802..1752ab44f9 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -3580,7 +3580,9 @@ type SendCoinsRequest struct { // The strategy to use for selecting coins. CoinSelectionStrategy CoinSelectionStrategy `protobuf:"varint,10,opt,name=coin_selection_strategy,json=coinSelectionStrategy,proto3,enum=lnrpc.CoinSelectionStrategy" json:"coin_selection_strategy,omitempty"` // A list of selected outpoints as inputs for the transaction. - Outpoints []*OutPoint `protobuf:"bytes,11,rep,name=outpoints,proto3" json:"outpoints,omitempty"` + Outpoints []*OutPoint `protobuf:"bytes,11,rep,name=outpoints,proto3" json:"outpoints,omitempty"` + // Destination address for the change UTXO + ChangeAddress string `protobuf:"bytes,12,opt,name=change_address,json=changeAddress,proto3" json:"change_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3693,6 +3695,13 @@ func (x *SendCoinsRequest) GetOutpoints() []*OutPoint { return nil } +func (x *SendCoinsRequest) GetChangeAddress() string { + if x != nil { + return x.ChangeAddress + } + return "" +} + type SendCoinsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The transaction ID of the transaction @@ -18670,7 +18679,7 @@ const file_lightning_proto_rawDesc = "" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"&\n" + "\x10SendManyResponse\x12\x12\n" + - "\x04txid\x18\x01 \x01(\tR\x04txid\"\xa9\x03\n" + + "\x04txid\x18\x01 \x01(\tR\x04txid\"\xd0\x03\n" + "\x10SendCoinsRequest\x12\x12\n" + "\x04addr\x18\x01 \x01(\tR\x04addr\x12\x16\n" + "\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1f\n" + @@ -18685,7 +18694,8 @@ const file_lightning_proto_rawDesc = "" + "\x11spend_unconfirmed\x18\t \x01(\bR\x10spendUnconfirmed\x12T\n" + "\x17coin_selection_strategy\x18\n" + " \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\x12-\n" + - "\toutpoints\x18\v \x03(\v2\x0f.lnrpc.OutPointR\toutpoints\"'\n" + + "\toutpoints\x18\v \x03(\v2\x0f.lnrpc.OutPointR\toutpoints\x12%\n" + + "\x0echange_address\x18\f \x01(\tR\rchangeAddress\"'\n" + "\x11SendCoinsResponse\x12\x12\n" + "\x04txid\x18\x01 \x01(\tR\x04txid\"h\n" + "\x12ListUnspentRequest\x12\x1b\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index be43c88a45..98da3499d2 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1155,6 +1155,9 @@ message SendCoinsRequest { // A list of selected outpoints as inputs for the transaction. repeated OutPoint outpoints = 11; + + // Destination address for the change UTXO + string change_address = 12; } message SendCoinsResponse { // The transaction ID of the transaction diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 7be82853d4..84468985a5 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -7543,6 +7543,10 @@ "$ref": "#/definitions/lnrpcOutPoint" }, "description": "A list of selected outpoints as inputs for the transaction." + }, + "change_address": { + "type": "string", + "title": "Destination address for the change UTXO" } } }, diff --git a/rpcserver.go b/rpcserver.go index 8b40192db8..7559741214 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -1071,6 +1071,34 @@ func addrPairsToOutputs(addrPairs map[string]int64, return outputs, nil } +// decodeAndValidateAddr decodes a Bitcoin address string, validates it is for +// the given network, and rejects raw pubkey hex strings to prevent unintended +// transfers. +func decodeAndValidateAddr(addrStr string, + params *chaincfg.Params) (btcutil.Address, error) { + + addr, err := btcutil.DecodeAddress(addrStr, params) + if err != nil { + return nil, err + } + + if !addr.IsForNet(params) { + return nil, fmt.Errorf("address: %v is not valid for this "+ + "network: %v", addr.String(), params.Name) + } + + // If the address parses to a valid pubkey, we assume the user + // accidentally tried to send funds to a bare pubkey address. This + // check is here to prevent unintended transfers. + decodedAddr, _ := hex.DecodeString(addrStr) + _, err = btcec.ParsePubKey(decodedAddr) + if err == nil { + return nil, fmt.Errorf("cannot send coins to pubkeys") + } + + return addr, nil +} + // allowCORS wraps the given http.Handler with a function that adds the // Access-Control-Allow-Origin header to the response. func allowCORS(handler http.Handler, origins []string) http.Handler { @@ -1129,7 +1157,8 @@ func allowCORS(handler http.Handler, origins []string) http.Handler { func (r *rpcServer) sendCoinsOnChain(paymentMap map[string]int64, feeRate chainfee.SatPerKWeight, minConfs int32, label string, strategy wallet.CoinSelectionStrategy, - selectedUtxos fn.Set[wire.OutPoint]) (*chainhash.Hash, error) { + selectedUtxos fn.Set[wire.OutPoint], + changeAddr btcutil.Address) (*chainhash.Hash, error) { outputs, err := addrPairsToOutputs(paymentMap, r.cfg.ActiveNetParams.Params) if err != nil { @@ -1139,7 +1168,8 @@ func (r *rpcServer) sendCoinsOnChain(paymentMap map[string]int64, // We first do a dry run, to sanity check we won't spend our wallet // balance below the reserved amount. authoredTx, err := r.server.cc.Wallet.CreateSimpleTx( - selectedUtxos, outputs, feeRate, minConfs, strategy, true, + selectedUtxos, outputs, changeAddr, feeRate, minConfs, + strategy, true, ) if err != nil { return nil, err @@ -1160,7 +1190,7 @@ func (r *rpcServer) sendCoinsOnChain(paymentMap map[string]int64, // If that checks out, we're fairly confident that creating sending to // these outputs will keep the wallet balance above the reserve. tx, err := r.server.cc.Wallet.SendOutputs( - selectedUtxos, outputs, feeRate, minConfs, label, strategy, + selectedUtxos, outputs, changeAddr, feeRate, minConfs, label, strategy, ) if err != nil { return nil, err @@ -1284,7 +1314,7 @@ func (r *rpcServer) EstimateFee(ctx context.Context, wallet := r.server.cc.Wallet err = wallet.WithCoinSelectLock(func() error { tx, err = wallet.CreateSimpleTx( - selectOutpoints, outputs, feePerKw, minConfs, + selectOutpoints, outputs, nil, feePerKw, minConfs, coinSelectionStrategy, true, ) return err @@ -1380,34 +1410,33 @@ func (r *rpcServer) SendCoins(ctx context.Context, return nil, err } - rpcsLog.Infof("[sendcoins] addr=%v, amt=%v, sat/kw=%v, min_confs=%v, "+ + rpcsLog.Infof("[sendcoins] addr=%v, amt=%v, sat/kw=%v, change_addr=%v, min_confs=%v, "+ "send_all=%v, select_outpoints=%v", - in.Addr, btcutil.Amount(in.Amount), int64(feePerKw), minConfs, + in.Addr, btcutil.Amount(in.Amount), int64(feePerKw), in.ChangeAddress, minConfs, in.SendAll, len(in.Outpoints)) - // Decode the address receiving the coins, we need to check whether the - // address is valid for this network. - targetAddr, err := btcutil.DecodeAddress( + targetAddr, err := decodeAndValidateAddr( in.Addr, r.cfg.ActiveNetParams.Params, ) if err != nil { return nil, err } - // Make the check on the decoded address according to the active network. - if !targetAddr.IsForNet(r.cfg.ActiveNetParams.Params) { - return nil, fmt.Errorf("address: %v is not valid for this "+ - "network: %v", targetAddr.String(), - r.cfg.ActiveNetParams.Params.Name) - } + var changeAddr btcutil.Address + if in.ChangeAddress != "" { + changeAddr, err = decodeAndValidateAddr( + in.ChangeAddress, r.cfg.ActiveNetParams.Params, + ) + if err != nil { + return nil, err + } - // If the destination address parses to a valid pubkey, we assume the user - // accidentally tried to send funds to a bare pubkey address. This check is - // here to prevent unintended transfers. - decodedAddr, _ := hex.DecodeString(in.Addr) - _, err = btcec.ParsePubKey(decodedAddr) - if err == nil { - return nil, fmt.Errorf("cannot send coins to pubkeys") + // Send all requires default interal wallet address to preserve + // internal reserved value mechanism + if in.SendAll { + return nil, fmt.Errorf("change_address cannot be set when " + + "send_all is active") + } } label, err := labels.ValidateAPI(in.Label) @@ -1464,9 +1493,10 @@ func (r *rpcServer) SendCoins(ctx context.Context, // With the sweeper instance created, we can now generate a // transaction that will sweep ALL outputs from the wallet in a // single transaction. This will be generated in a concurrent - // safe manner, so no need to worry about locking. The tx will - // pay to the change address created above if we needed to - // reserve any value, the rest will go to targetAddr. + // safe manner, so no need to worry about locking. If the wallet + // has a reserved value, a second output will be added below to + // return those funds to an internal wallet reserve address; the rest + // will go to targetAddr. sweepTxPkg, err := sweep.CraftSweepAllTx( feePerKw, maxFeeRate, uint32(bestHeight), nil, targetAddr, wallet, wallet, wallet.WalletController, @@ -1503,7 +1533,7 @@ func (r *rpcServer) SendCoins(ctx context.Context, // where we'll send this reserved value back to. This // ensures this is an address the wallet knows about, // allowing us to pass the reserved value check. - changeAddr, err := r.server.cc.Wallet.NewAddress( + reserveAddr, err := r.server.cc.Wallet.NewAddress( lnwallet.TaprootPubkey, true, lnwallet.DefaultAccountName, ) @@ -1515,7 +1545,7 @@ func (r *rpcServer) SendCoins(ctx context.Context, // remaining funds will go to the targetAddr. outputs := []sweep.DeliveryAddr{ { - Addr: changeAddr, + Addr: reserveAddr, Amt: reservedVal, }, } @@ -1569,7 +1599,7 @@ func (r *rpcServer) SendCoins(ctx context.Context, txid = &sweepTXID } else { - // We'll now construct out payment map, and use the wallet's + // We'll now construct our payment map, and use the wallet's // coin selection synchronization method to ensure that no coin // selection (funding, sweep alls, other sends) can proceed // while we instruct the wallet to send this transaction. @@ -1578,6 +1608,7 @@ func (r *rpcServer) SendCoins(ctx context.Context, newTXID, err := r.sendCoinsOnChain( paymentMap, feePerKw, minConfs, label, coinSelectionStrategy, selectOutpoints, + changeAddr, ) if err != nil { return err @@ -1649,7 +1680,7 @@ func (r *rpcServer) SendMany(ctx context.Context, err = wallet.WithCoinSelectLock(func() error { sendManyTXID, err := r.sendCoinsOnChain( in.AddrToAmount, feePerKw, minConfs, label, - coinSelectionStrategy, nil, + coinSelectionStrategy, nil, nil, ) if err != nil { return err From baf59c2179347244716a7c32e5435f623515b6a8 Mon Sep 17 00:00:00 2001 From: murraystewart96 Date: Sat, 16 May 2026 19:27:26 +0100 Subject: [PATCH 2/5] lnwallet: adding change addr to required functions in WalletController lnwallet: adding custom change addr support to btcwallet walletcontroller implementation lnwallet: updating functions to use new signature lnwallet: updating functions to use new signature lnwallet: small formatting change --- lntest/mock/walletcontroller.go | 8 ++++---- lnwallet/btcwallet/btcwallet.go | 27 +++++++++++++++++++-------- lnwallet/interface.go | 10 ++++++---- lnwallet/mock.go | 4 ++-- lnwallet/rpcwallet/rpcwallet.go | 6 +++--- lnwallet/test/test_interface.go | 2 +- 6 files changed, 35 insertions(+), 22 deletions(-) diff --git a/lntest/mock/walletcontroller.go b/lntest/mock/walletcontroller.go index fa623bf84d..771be06895 100644 --- a/lntest/mock/walletcontroller.go +++ b/lntest/mock/walletcontroller.go @@ -145,16 +145,16 @@ func (w *WalletController) ImportTaprootScript(waddrmgr.KeyScope, // SendOutputs currently returns dummy values. func (w *WalletController) SendOutputs(fn.Set[wire.OutPoint], []*wire.TxOut, - chainfee.SatPerKWeight, int32, string, base.CoinSelectionStrategy) ( - *wire.MsgTx, error) { + btcutil.Address, chainfee.SatPerKWeight, int32, string, + base.CoinSelectionStrategy) (*wire.MsgTx, error) { return nil, nil } // CreateSimpleTx currently returns dummy values. func (w *WalletController) CreateSimpleTx(fn.Set[wire.OutPoint], []*wire.TxOut, - chainfee.SatPerKWeight, int32, base.CoinSelectionStrategy, - bool) (*txauthor.AuthoredTx, error) { + btcutil.Address, chainfee.SatPerKWeight, int32, + base.CoinSelectionStrategy, bool) (*txauthor.AuthoredTx, error) { return nil, nil } diff --git a/lnwallet/btcwallet/btcwallet.go b/lnwallet/btcwallet/btcwallet.go index 354238d59d..b8cf576b87 100644 --- a/lnwallet/btcwallet/btcwallet.go +++ b/lnwallet/btcwallet/btcwallet.go @@ -904,8 +904,8 @@ func (b *BtcWallet) ImportTaprootScript(scope waddrmgr.KeyScope, // // This is a part of the WalletController interface. func (b *BtcWallet) SendOutputs(inputs fn.Set[wire.OutPoint], - outputs []*wire.TxOut, feeRate chainfee.SatPerKWeight, - minConfs int32, label string, + outputs []*wire.TxOut, changeAddr btcutil.Address, + feeRate chainfee.SatPerKWeight, minConfs int32, label string, strategy base.CoinSelectionStrategy) (*wire.MsgTx, error) { // Convert our fee rate from sat/kw to sat/kb since it's required by @@ -922,17 +922,22 @@ func (b *BtcWallet) SendOutputs(inputs fn.Set[wire.OutPoint], return nil, lnwallet.ErrInvalidMinconf } + var opts []base.TxCreateOption + if changeAddr != nil { + opts = append(opts, base.WithChangeAddress(changeAddr)) + } + // Use selected UTXOs if specified, otherwise default selection. if len(inputs) != 0 { return b.wallet.SendOutputsWithInput( outputs, nil, defaultAccount, minConfs, feeSatPerKB, - strategy, label, inputs.ToSlice(), + strategy, label, inputs.ToSlice(), opts..., ) } return b.wallet.SendOutputs( outputs, nil, defaultAccount, minConfs, feeSatPerKB, - strategy, label, + strategy, label, opts..., ) } @@ -950,7 +955,8 @@ func (b *BtcWallet) SendOutputs(inputs fn.Set[wire.OutPoint], // // This is a part of the WalletController interface. func (b *BtcWallet) CreateSimpleTx(inputs fn.Set[wire.OutPoint], - outputs []*wire.TxOut, feeRate chainfee.SatPerKWeight, minConfs int32, + outputs []*wire.TxOut, changeAddr btcutil.Address, + feeRate chainfee.SatPerKWeight, minConfs int32, strategy base.CoinSelectionStrategy, dryRun bool) ( *txauthor.AuthoredTx, error) { @@ -982,12 +988,17 @@ func (b *BtcWallet) CreateSimpleTx(inputs fn.Set[wire.OutPoint], } } - // Add the optional inputs to the transaction. - optFunc := base.WithCustomSelectUtxos(inputs.ToSlice()) + opts := []base.TxCreateOption{ + base.WithCustomSelectUtxos(inputs.ToSlice()), + } + + if changeAddr != nil { + opts = append(opts, base.WithChangeAddress(changeAddr)) + } return b.wallet.CreateSimpleTx( nil, defaultAccount, outputs, minConfs, feeSatPerKB, - strategy, dryRun, []base.TxCreateOption{optFunc}..., + strategy, dryRun, opts..., ) } diff --git a/lnwallet/interface.go b/lnwallet/interface.go index f5a717d3f5..6932d700ad 100644 --- a/lnwallet/interface.go +++ b/lnwallet/interface.go @@ -353,7 +353,8 @@ type WalletController interface { // // NOTE: This method requires the global coin selection lock to be held. SendOutputs(inputs fn.Set[wire.OutPoint], outputs []*wire.TxOut, - feeRate chainfee.SatPerKWeight, minConfs int32, label string, + changeAddr btcutil.Address, feeRate chainfee.SatPerKWeight, + minConfs int32, label string, strategy base.CoinSelectionStrategy) (*wire.MsgTx, error) // CreateSimpleTx creates a Bitcoin transaction paying to the specified @@ -369,9 +370,10 @@ type WalletController interface { // // NOTE: This method requires the global coin selection lock to be held. CreateSimpleTx(inputs fn.Set[wire.OutPoint], outputs []*wire.TxOut, - feeRate chainfee.SatPerKWeight, minConfs int32, - strategy base.CoinSelectionStrategy, dryRun bool) ( - *txauthor.AuthoredTx, error) + changeAddr btcutil.Address, feeRate chainfee.SatPerKWeight, + minConfs int32, strategy base.CoinSelectionStrategy, + dryRun bool, + ) (*txauthor.AuthoredTx, error) // GetTransactionDetails returns a detailed description of a transaction // given its transaction hash. diff --git a/lnwallet/mock.go b/lnwallet/mock.go index 39e520d276..a34e785b6a 100644 --- a/lnwallet/mock.go +++ b/lnwallet/mock.go @@ -155,7 +155,7 @@ func (w *mockWalletController) ImportTaprootScript(waddrmgr.KeyScope, // SendOutputs currently returns dummy values. func (w *mockWalletController) SendOutputs(fn.Set[wire.OutPoint], []*wire.TxOut, - chainfee.SatPerKWeight, int32, string, + btcutil.Address, chainfee.SatPerKWeight, int32, string, base.CoinSelectionStrategy) (*wire.MsgTx, error) { return nil, nil @@ -163,7 +163,7 @@ func (w *mockWalletController) SendOutputs(fn.Set[wire.OutPoint], []*wire.TxOut, // CreateSimpleTx currently returns dummy values. func (w *mockWalletController) CreateSimpleTx(fn.Set[wire.OutPoint], - []*wire.TxOut, chainfee.SatPerKWeight, int32, + []*wire.TxOut, btcutil.Address, chainfee.SatPerKWeight, int32, base.CoinSelectionStrategy, bool) (*txauthor.AuthoredTx, error) { return nil, nil diff --git a/lnwallet/rpcwallet/rpcwallet.go b/lnwallet/rpcwallet/rpcwallet.go index 196e5c5c41..aed495467f 100644 --- a/lnwallet/rpcwallet/rpcwallet.go +++ b/lnwallet/rpcwallet/rpcwallet.go @@ -122,12 +122,12 @@ func (r *RPCKeyRing) NewAddress(addrType lnwallet.AddressType, change bool, // // NOTE: This method only signs with BIP49/84 keys. func (r *RPCKeyRing) SendOutputs(inputs fn.Set[wire.OutPoint], - outputs []*wire.TxOut, feeRate chainfee.SatPerKWeight, - minConfs int32, label string, + outputs []*wire.TxOut, changeAddr btcutil.Address, + feeRate chainfee.SatPerKWeight, minConfs int32, label string, strategy basewallet.CoinSelectionStrategy) (*wire.MsgTx, error) { tx, err := r.WalletController.SendOutputs( - inputs, outputs, feeRate, minConfs, label, strategy, + inputs, outputs, changeAddr, feeRate, minConfs, label, strategy, ) if err != nil && err != basewallet.ErrTxUnsigned { return nil, err diff --git a/lnwallet/test/test_interface.go b/lnwallet/test/test_interface.go index 8d180ca100..447a807235 100644 --- a/lnwallet/test/test_interface.go +++ b/lnwallet/test/test_interface.go @@ -2668,7 +2668,7 @@ func testCreateSimpleTx(r *rpctest.Harness, w *lnwallet.LightningWallet, // Now try creating a tx spending to these outputs. createTx, createErr := w.CreateSimpleTx( - nil, outputs, feeRate, minConfs, + nil, outputs, nil, feeRate, minConfs, w.Cfg.CoinSelectionStrategy, true, ) switch { From 5aff71693c1cc2716b52384391b5c781f5da0f21 Mon Sep 17 00:00:00 2001 From: murraystewart96 Date: Sat, 16 May 2026 19:48:07 +0100 Subject: [PATCH 3/5] lnrpc/walletrpc: add change_address to WalletKit SendCoins and SendMany --- cmd/commands/commands.go | 7 +++++++ lnrpc/lightning.pb.go | 23 ++++++++++++++++++----- lnrpc/lightning.proto | 7 ++++++- lnrpc/lightning.swagger.json | 6 +++++- lnrpc/walletrpc/walletkit.pb.go | 19 +++++++++++++++---- lnrpc/walletrpc/walletkit.proto | 6 ++++++ lnrpc/walletrpc/walletkit.swagger.json | 4 ++++ lnrpc/walletrpc/walletkit_server.go | 24 +++++++++++++++++++++++- lnwallet/test/test_interface.go | 18 +++++++++--------- rpcserver.go | 14 ++++++++++++-- 10 files changed, 105 insertions(+), 23 deletions(-) diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index 0c389a610b..fd8d584616 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -856,6 +856,12 @@ var sendManyCommand = cli.Command{ }, coinSelectionStrategyFlag, txLabelFlag, + cli.StringFlag{ + Name: "change_address", + Usage: "(optional) a bitcoin address to send change to. " + + "If not set, change is sent to a wallet-derived " + + "address.", + }, }, Action: actionDecorator(sendMany), } @@ -902,6 +908,7 @@ func sendMany(ctx *cli.Context) error { MinConfs: minConfs, SpendUnconfirmed: minConfs == 0, CoinSelectionStrategy: coinSelectionStrategy, + ChangeAddress: ctx.String("change_address"), }) if err != nil { return err diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 1752ab44f9..51c0219023 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -3413,8 +3413,11 @@ type SendManyRequest struct { SpendUnconfirmed bool `protobuf:"varint,8,opt,name=spend_unconfirmed,json=spendUnconfirmed,proto3" json:"spend_unconfirmed,omitempty"` // The strategy to use for selecting coins during sending many requests. CoinSelectionStrategy CoinSelectionStrategy `protobuf:"varint,9,opt,name=coin_selection_strategy,json=coinSelectionStrategy,proto3,enum=lnrpc.CoinSelectionStrategy" json:"coin_selection_strategy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // An optional address to send change to. If not set, a wallet-derived + // address is used. + ChangeAddress string `protobuf:"bytes,10,opt,name=change_address,json=changeAddress,proto3" json:"change_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendManyRequest) Reset() { @@ -3504,6 +3507,13 @@ func (x *SendManyRequest) GetCoinSelectionStrategy() CoinSelectionStrategy { return CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG } +func (x *SendManyRequest) GetChangeAddress() string { + if x != nil { + return x.ChangeAddress + } + return "" +} + type SendManyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The id of the transaction @@ -3581,7 +3591,8 @@ type SendCoinsRequest struct { CoinSelectionStrategy CoinSelectionStrategy `protobuf:"varint,10,opt,name=coin_selection_strategy,json=coinSelectionStrategy,proto3,enum=lnrpc.CoinSelectionStrategy" json:"coin_selection_strategy,omitempty"` // A list of selected outpoints as inputs for the transaction. Outpoints []*OutPoint `protobuf:"bytes,11,rep,name=outpoints,proto3" json:"outpoints,omitempty"` - // Destination address for the change UTXO + // An optional address to send change to. If not set, a wallet-derived + // address is used. ChangeAddress string `protobuf:"bytes,12,opt,name=change_address,json=changeAddress,proto3" json:"change_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -18663,7 +18674,7 @@ const file_lightning_proto_rawDesc = "" + "\afee_sat\x18\x01 \x01(\x03R\x06feeSat\x123\n" + "\x14feerate_sat_per_byte\x18\x02 \x01(\x03B\x02\x18\x01R\x11feerateSatPerByte\x12\"\n" + "\rsat_per_vbyte\x18\x03 \x01(\x04R\vsatPerVbyte\x12'\n" + - "\x06inputs\x18\x04 \x03(\v2\x0f.lnrpc.OutPointR\x06inputs\"\xc1\x03\n" + + "\x06inputs\x18\x04 \x03(\v2\x0f.lnrpc.OutPointR\x06inputs\"\xe8\x03\n" + "\x0fSendManyRequest\x12L\n" + "\fAddrToAmount\x18\x01 \x03(\v2(.lnrpc.SendManyRequest.AddrToAmountEntryR\fAddrToAmount\x12\x1f\n" + "\vtarget_conf\x18\x03 \x01(\x05R\n" + @@ -18674,7 +18685,9 @@ const file_lightning_proto_rawDesc = "" + "\x05label\x18\x06 \x01(\tR\x05label\x12\x1b\n" + "\tmin_confs\x18\a \x01(\x05R\bminConfs\x12+\n" + "\x11spend_unconfirmed\x18\b \x01(\bR\x10spendUnconfirmed\x12T\n" + - "\x17coin_selection_strategy\x18\t \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\x1a?\n" + + "\x17coin_selection_strategy\x18\t \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\x12%\n" + + "\x0echange_address\x18\n" + + " \x01(\tR\rchangeAddress\x1a?\n" + "\x11AddrToAmountEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01\"&\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 98da3499d2..7d7a1db0cd 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1108,6 +1108,10 @@ message SendManyRequest { // The strategy to use for selecting coins during sending many requests. CoinSelectionStrategy coin_selection_strategy = 9; + + // An optional address to send change to. If not set, a wallet-derived + // address is used. + string change_address = 10; } message SendManyResponse { // The id of the transaction @@ -1156,7 +1160,8 @@ message SendCoinsRequest { // A list of selected outpoints as inputs for the transaction. repeated OutPoint outpoints = 11; - // Destination address for the change UTXO + // An optional address to send change to. If not set, a wallet-derived + // address is used. string change_address = 12; } message SendCoinsResponse { diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 84468985a5..373da07420 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -7546,7 +7546,7 @@ }, "change_address": { "type": "string", - "title": "Destination address for the change UTXO" + "description": "An optional address to send change to. If not set, a wallet-derived\naddress is used." } } }, @@ -7630,6 +7630,10 @@ "coin_selection_strategy": { "$ref": "#/definitions/lnrpcCoinSelectionStrategy", "description": "The strategy to use for selecting coins during sending many requests." + }, + "change_address": { + "type": "string", + "description": "An optional address to send change to. If not set, a wallet-derived\naddress is used." } } }, diff --git a/lnrpc/walletrpc/walletkit.pb.go b/lnrpc/walletrpc/walletkit.pb.go index ef8e84ee18..8bd547802a 100644 --- a/lnrpc/walletrpc/walletkit.pb.go +++ b/lnrpc/walletrpc/walletkit.pb.go @@ -2522,8 +2522,11 @@ type SendOutputsRequest struct { SpendUnconfirmed bool `protobuf:"varint,5,opt,name=spend_unconfirmed,json=spendUnconfirmed,proto3" json:"spend_unconfirmed,omitempty"` // The strategy to use for selecting coins during sending the outputs. CoinSelectionStrategy lnrpc.CoinSelectionStrategy `protobuf:"varint,6,opt,name=coin_selection_strategy,json=coinSelectionStrategy,proto3,enum=lnrpc.CoinSelectionStrategy" json:"coin_selection_strategy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // An optional address to send change to. If not set, a wallet-derived + // address is used. + ChangeAddress string `protobuf:"bytes,7,opt,name=change_address,json=changeAddress,proto3" json:"change_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SendOutputsRequest) Reset() { @@ -2598,6 +2601,13 @@ func (x *SendOutputsRequest) GetCoinSelectionStrategy() lnrpc.CoinSelectionStrat return lnrpc.CoinSelectionStrategy(0) } +func (x *SendOutputsRequest) GetChangeAddress() string { + if x != nil { + return x.ChangeAddress + } + return "" +} + type SendOutputsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The serialized transaction sent out on the network. @@ -4647,7 +4657,7 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x0fPublishResponse\x12#\n" + "\rpublish_error\x18\x01 \x01(\tR\fpublishError\"3\n" + "\x19RemoveTransactionResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\"\x92\x02\n" + + "\x06status\x18\x01 \x01(\tR\x06status\"\xb9\x02\n" + "\x12SendOutputsRequest\x12\x1c\n" + "\n" + "sat_per_kw\x18\x01 \x01(\x03R\bsatPerKw\x12(\n" + @@ -4655,7 +4665,8 @@ const file_walletrpc_walletkit_proto_rawDesc = "" + "\x05label\x18\x03 \x01(\tR\x05label\x12\x1b\n" + "\tmin_confs\x18\x04 \x01(\x05R\bminConfs\x12+\n" + "\x11spend_unconfirmed\x18\x05 \x01(\bR\x10spendUnconfirmed\x12T\n" + - "\x17coin_selection_strategy\x18\x06 \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\",\n" + + "\x17coin_selection_strategy\x18\x06 \x01(\x0e2\x1c.lnrpc.CoinSelectionStrategyR\x15coinSelectionStrategy\x12%\n" + + "\x0echange_address\x18\a \x01(\tR\rchangeAddress\",\n" + "\x13SendOutputsResponse\x12\x15\n" + "\x06raw_tx\x18\x01 \x01(\fR\x05rawTx\"5\n" + "\x12EstimateFeeRequest\x12\x1f\n" + diff --git a/lnrpc/walletrpc/walletkit.proto b/lnrpc/walletrpc/walletkit.proto index 81176d9091..0ca8fe22a0 100644 --- a/lnrpc/walletrpc/walletkit.proto +++ b/lnrpc/walletrpc/walletkit.proto @@ -830,6 +830,12 @@ message SendOutputsRequest { // The strategy to use for selecting coins during sending the outputs. lnrpc.CoinSelectionStrategy coin_selection_strategy = 6; + + /* + An optional address to send change to. If not set, a wallet-derived + address is used. + */ + string change_address = 7; } message SendOutputsResponse { /* diff --git a/lnrpc/walletrpc/walletkit.swagger.json b/lnrpc/walletrpc/walletkit.swagger.json index e5a0946c10..8ba83ab04c 100644 --- a/lnrpc/walletrpc/walletkit.swagger.json +++ b/lnrpc/walletrpc/walletkit.swagger.json @@ -2116,6 +2116,10 @@ "coin_selection_strategy": { "$ref": "#/definitions/lnrpcCoinSelectionStrategy", "description": "The strategy to use for selecting coins during sending the outputs." + }, + "change_address": { + "type": "string", + "description": "An optional address to send change to. If not set, a wallet-derived\naddress is used." } } }, diff --git a/lnrpc/walletrpc/walletkit_server.go b/lnrpc/walletrpc/walletkit_server.go index bece6001d6..81b0e4ba07 100644 --- a/lnrpc/walletrpc/walletkit_server.go +++ b/lnrpc/walletrpc/walletkit_server.go @@ -8,6 +8,7 @@ import ( "context" "encoding/base64" "encoding/binary" + "encoding/hex" "errors" "fmt" "maps" @@ -823,11 +824,32 @@ func (w *WalletKit) SendOutputs(ctx context.Context, return nil, err } + var changeAddr btcutil.Address + if req.ChangeAddress != "" { + changeAddr, err = btcutil.DecodeAddress( + req.ChangeAddress, w.cfg.ChainParams, + ) + if err != nil { + return nil, fmt.Errorf("invalid change_address: %w", err) + } + if !changeAddr.IsForNet(w.cfg.ChainParams) { + return nil, fmt.Errorf("change_address %v is not valid "+ + "for network %v", req.ChangeAddress, + w.cfg.ChainParams.Name) + } + decodedChangeAddr, _ := hex.DecodeString(req.ChangeAddress) + if _, err = btcec.ParsePubKey(decodedChangeAddr); err == nil { + return nil, fmt.Errorf("cannot send change to a bare " + + "public key") + } + } + // Now that we have the outputs mapped and checked for the reserve // requirement, we can request that the wallet attempts to create this // transaction. tx, err := w.cfg.Wallet.SendOutputs( - nil, outputsToCreate, chainfee.SatPerKWeight(req.SatPerKw), + nil, outputsToCreate, changeAddr, + chainfee.SatPerKWeight(req.SatPerKw), minConfs, label, coinSelectionStrategy, ) if err != nil { diff --git a/lnwallet/test/test_interface.go b/lnwallet/test/test_interface.go index 447a807235..2b687b4279 100644 --- a/lnwallet/test/test_interface.go +++ b/lnwallet/test/test_interface.go @@ -171,7 +171,7 @@ func sendCoins(t *testing.T, miner *rpctest.Harness, t.Helper() tx, err := sender.SendOutputs( - nil, []*wire.TxOut{output}, feeRate, minConf, labels.External, + nil, []*wire.TxOut{output}, nil, feeRate, minConf, labels.External, sender.Cfg.CoinSelectionStrategy, ) require.NoError(t, err, "unable to send transaction") @@ -1194,7 +1194,7 @@ func testListTransactionDetails(miner *rpctest.Harness, require.NoError(t, err, "unable to make output script") burnOutput := wire.NewTxOut(outputAmt, outputScript) burnTX, err := alice.SendOutputs( - nil, []*wire.TxOut{burnOutput}, 2500, 1, labels.External, + nil, []*wire.TxOut{burnOutput}, nil, 2500, 1, labels.External, alice.Cfg.CoinSelectionStrategy, ) require.NoError(t, err, "unable to create burn tx") @@ -1559,7 +1559,7 @@ func testTransactionSubscriptions(miner *rpctest.Harness, burnOutput := wire.NewTxOut(outputAmt, outputScript) tx, err := alice.SendOutputs( - nil, []*wire.TxOut{burnOutput}, 2500, 1, labels.External, + nil, []*wire.TxOut{burnOutput}, nil, 2500, 1, labels.External, alice.Cfg.CoinSelectionStrategy, ) require.NoError(t, err, "unable to create tx") @@ -1748,7 +1748,7 @@ func newTx(t *testing.T, r *rpctest.Harness, pubKey *btcec.PublicKey, PkScript: keyScript, } tx, err := alice.SendOutputs( - nil, []*wire.TxOut{newOutput}, 2500, 1, labels.External, + nil, []*wire.TxOut{newOutput}, nil, 2500, 1, labels.External, alice.Cfg.CoinSelectionStrategy, ) require.NoError(t, err, "unable to create output") @@ -2055,7 +2055,7 @@ func testSignOutputUsingTweaks(r *rpctest.Harness, PkScript: keyScript, } tx, err := alice.SendOutputs( - nil, []*wire.TxOut{newOutput}, 2500, 1, labels.External, + nil, []*wire.TxOut{newOutput}, nil, 2500, 1, labels.External, alice.Cfg.CoinSelectionStrategy, ) if err != nil { @@ -2174,7 +2174,7 @@ func testReorgWalletBalance(r *rpctest.Harness, w *lnwallet.LightningWallet, PkScript: script, } tx, err := w.SendOutputs( - nil, []*wire.TxOut{output}, 2500, 1, labels.External, + nil, []*wire.TxOut{output}, nil, 2500, 1, labels.External, w.Cfg.CoinSelectionStrategy, ) require.NoError(t, err, "unable to send outputs") @@ -2399,7 +2399,7 @@ func testSpendUnconfirmed(miner *rpctest.Harness, PkScript: alicePkScript, } _, err = bob.SendOutputs( - nil, []*wire.TxOut{output}, txFeeRate, 0, labels.External, + nil, []*wire.TxOut{output}, nil, txFeeRate, 0, labels.External, bob.Cfg.CoinSelectionStrategy, ) if err == nil { @@ -2426,7 +2426,7 @@ func testSpendUnconfirmed(miner *rpctest.Harness, // First, verify that we don't have enough balance to send the coins // using confirmed outputs only. _, err = bob.SendOutputs( - nil, []*wire.TxOut{output}, txFeeRate, 1, labels.External, + nil, []*wire.TxOut{output}, nil, txFeeRate, 1, labels.External, bob.Cfg.CoinSelectionStrategy, ) if err == nil { @@ -2687,7 +2687,7 @@ func testCreateSimpleTx(r *rpctest.Harness, w *lnwallet.LightningWallet, // only difference is that the dry run tx is not signed, and // that the change output position might be different. tx, sendErr := w.SendOutputs( - nil, outputs, feeRate, minConfs, labels.External, + nil, outputs, nil, feeRate, minConfs, labels.External, w.Cfg.CoinSelectionStrategy, ) switch { diff --git a/rpcserver.go b/rpcserver.go index 7559741214..bbf3d9f2fc 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -1431,7 +1431,7 @@ func (r *rpcServer) SendCoins(ctx context.Context, return nil, err } - // Send all requires default interal wallet address to preserve + // Send all requires default internal wallet address to preserve // internal reserved value mechanism if in.SendAll { return nil, fmt.Errorf("change_address cannot be set when " + @@ -1671,6 +1671,16 @@ func (r *rpcServer) SendMany(ctx context.Context, rpcsLog.Infof("[sendmany] outputs=%v, sat/kw=%v", lnutils.SpewLogClosure(in.AddrToAmount), int64(feePerKw)) + var changeAddr btcutil.Address + if in.ChangeAddress != "" { + changeAddr, err = decodeAndValidateAddr( + in.ChangeAddress, r.cfg.ActiveNetParams.Params, + ) + if err != nil { + return nil, err + } + } + var txid *chainhash.Hash // We'll attempt to send to the target set of outputs, ensuring that we @@ -1680,7 +1690,7 @@ func (r *rpcServer) SendMany(ctx context.Context, err = wallet.WithCoinSelectLock(func() error { sendManyTXID, err := r.sendCoinsOnChain( in.AddrToAmount, feePerKw, minConfs, label, - coinSelectionStrategy, nil, nil, + coinSelectionStrategy, nil, changeAddr, ) if err != nil { return err From 7d996ebffc6381f2f9592f2fa576c9ce3aabf3c4 Mon Sep 17 00:00:00 2001 From: murraystewart96 Date: Sat, 16 May 2026 19:48:51 +0100 Subject: [PATCH 4/5] cmd: adding change_addr flag to command --- cmd/commands/commands.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index fd8d584616..08648d89d3 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -536,6 +536,12 @@ var sendCoinsCommand = cli.Command{ "the amt flag", }, txLabelFlag, + cli.StringFlag{ + Name: "change_address", + Usage: "(optional) a bitcoin address to send change to. " + + "If not set, change is sent to a wallet-derived " + + "address. Cannot be used with --sweepall.", + }, }, Action: actionDecorator(sendCoins), } @@ -598,6 +604,11 @@ func sendCoins(ctx *cli.Context) error { "sweep all coins out of the wallet") } + if ctx.IsSet("change_address") && ctx.Bool("sweepall") { + return fmt.Errorf("change_address cannot be set when " + + "sweepall is active") + } + coinSelectionStrategy, err := parseCoinSelectionStrategy(ctx) if err != nil { return err @@ -682,6 +693,7 @@ func sendCoins(ctx *cli.Context) error { SpendUnconfirmed: minConfs == 0, CoinSelectionStrategy: coinSelectionStrategy, Outpoints: outpoints, + ChangeAddress: ctx.String("change_address"), } txid, err := client.SendCoins(ctxc, req) if err != nil { From 1d3096b132d7b5dad5f69025b48f9ab5a69fb337 Mon Sep 17 00:00:00 2001 From: murraystewart96 Date: Sat, 16 May 2026 20:38:50 +0100 Subject: [PATCH 5/5] lnwallet: adding integration test for creating a transaction with change addr --- lnwallet/test/test_interface.go | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/lnwallet/test/test_interface.go b/lnwallet/test/test_interface.go index 2b687b4279..b754898fcc 100644 --- a/lnwallet/test/test_interface.go +++ b/lnwallet/test/test_interface.go @@ -2787,6 +2787,76 @@ func testCreateSimpleTx(r *rpctest.Harness, w *lnwallet.LightningWallet, } } +// testCreateSimpleTxChangeAddr verifies that when a custom change address is +// provided to CreateSimpleTx and SendOutputs, the resulting transaction sends +// change to that address rather than a wallet-derived one. +func testCreateSimpleTxChangeAddr(r *rpctest.Harness, + w *lnwallet.LightningWallet, _ *lnwallet.LightningWallet, + t *testing.T) { + + err := loadTestCredits(r, w, 20, 4) + require.NoError(t, err, "unable to load test credits") + + // Use a miner address as the change destination — it is external to + // the wallet, simulating a user-supplied change address. + changeAddr, err := r.NewAddress() + require.NoError(t, err, "unable to get miner address for change") + + changeScript, err := txscript.PayToAddrScript(changeAddr) + require.NoError(t, err, "unable to build change script") + + // Build a payment output to send 1 BTC. The funded wallet has 20 BTC + // so a change output must be produced. + destAddr, err := r.NewAddress() + require.NoError(t, err, "unable to get destination address") + + destScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err, "unable to build dest script") + + outputs := []*wire.TxOut{{ + Value: btcutil.SatoshiPerBitcoin, + PkScript: destScript, + }} + + const feeRate = chainfee.SatPerKWeight(2500) + + // Dry-run first: CreateSimpleTx should produce a tx whose change + // output uses the caller-supplied script. + createTx, err := w.CreateSimpleTx( + nil, outputs, changeAddr, feeRate, 1, + w.Cfg.CoinSelectionStrategy, true, + ) + require.NoError(t, err, "CreateSimpleTx with change addr failed") + require.NotEqual(t, createTx.ChangeIndex, -1, + "expected a change output in dry-run tx") + + dryChangeOut := createTx.Tx.TxOut[createTx.ChangeIndex] + require.Equal(t, changeScript, dryChangeOut.PkScript, + "dry-run change output does not use the custom change address") + + // Now broadcast via SendOutputs and confirm the change output in the + // actual transaction also goes to the custom address. + tx, err := w.SendOutputs( + nil, outputs, changeAddr, feeRate, 1, labels.External, + w.Cfg.CoinSelectionStrategy, + ) + require.NoError(t, err, "SendOutputs with change addr failed") + + txid := tx.TxHash() + err = waitForMempoolTx(r, &txid) + require.NoError(t, err, "tx not relayed to miner") + + var changeOut *wire.TxOut + for _, out := range tx.TxOut { + if bytes.Equal(out.PkScript, changeScript) { + changeOut = out + break + } + } + require.NotNil(t, changeOut, + "broadcasted tx missing change output to custom address") +} + // testSignOutputCreateAccount tests that we're able to properly sign for an // output if the target account hasn't yet been created on disk. In this case, // we'll create the account, then sign. @@ -2948,6 +3018,10 @@ var walletTests = []walletTestCase{ name: "create simple tx", test: testCreateSimpleTx, }, + { + name: "create simple tx change addr", + test: testCreateSimpleTxChangeAddr, + }, { name: "test sign create account", test: testSignOutputCreateAccount,