diff --git a/wallet/createtx.go b/wallet/createtx.go index c81c60b917..ab2c70a5bb 100644 --- a/wallet/createtx.go +++ b/wallet/createtx.go @@ -142,7 +142,8 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, account uint32, minconf int32, feeSatPerKb btcutil.Amount, strategy CoinSelectionStrategy, dryRun bool, selectedUtxos []wire.OutPoint, - allowUtxo func(utxo wtxmgr.Credit) bool) ( + allowUtxo func(utxo wtxmgr.Credit) bool, + changeAddr btcutil.Address) ( *txauthor.AuthoredTx, error) { chainClient, err := w.requireChainClient() @@ -176,7 +177,7 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, var tx *txauthor.AuthoredTx err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { addrmgrNs, changeSource, err := w.addrMgrWithChangeSource( - dbtx, changeKeyScope, account, + dbtx, changeKeyScope, account, changeAddr, ) if err != nil { return err @@ -319,8 +320,11 @@ func (w *Wallet) txToOutputs(outputs []*wire.TxOut, // Finally, we'll request the backend to notify us of the // transaction that pays to the change address, if there is one, - // when it confirms. - if tx.ChangeIndex >= 0 { + // when it confirms. We skip this for external change addresses + // since they are not wallet-owned — registering them would cause + // future payments to that address to be treated as wallet-relevant + // transactions. + if tx.ChangeIndex >= 0 && changeAddr == nil { changePkScript := tx.Tx.TxOut[tx.ChangeIndex].PkScript _, addrs, _, err := txscript.ExtractPkScriptAddrs( changePkScript, w.chainParams, @@ -429,14 +433,32 @@ func inputYieldsPositively(credit *wire.TxOut, } // addrMgrWithChangeSource returns the address manager bucket and a change -// source that returns change addresses from said address manager. The change -// addresses will come from the specified key scope and account, unless a key -// scope is not specified. In that case, change addresses will always come from -// the P2WKH key scope. +// source. If changeAddr is non-nil, the change source always pays to that +// address. Otherwise, change addresses are derived from the wallet using the +// specified key scope and account (defaulting to P2TR if no scope is given). func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, - changeKeyScope *waddrmgr.KeyScope, account uint32) ( + changeKeyScope *waddrmgr.KeyScope, account uint32, + changeAddr btcutil.Address) ( walletdb.ReadWriteBucket, *txauthor.ChangeSource, error) { + addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) + + if changeAddr != nil { + if !changeAddr.IsForNet(w.chainParams) { + return nil, nil, fmt.Errorf("change address %v is not "+ + "for network %v", changeAddr, w.chainParams.Name) + } + + pkScript, err := txscript.PayToAddrScript(changeAddr) + if err != nil { + return nil, nil, err + } + return addrmgrNs, &txauthor.ChangeSource{ + ScriptSize: len(pkScript), + NewScript: func() ([]byte, error) { return pkScript, nil }, + }, nil + } + // Determine the address type for change addresses of the given // account. if changeKeyScope == nil { @@ -446,7 +468,6 @@ func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, // It's possible for the account to have an address schema override, so // prefer that if it exists. - addrmgrNs := dbtx.ReadWriteBucket(waddrmgrNamespaceKey) scopeMgr, err := w.Manager.FetchScopedKeyManager(*changeKeyScope) if err != nil { return nil, nil, err @@ -480,22 +501,22 @@ func (w *Wallet) addrMgrWithChangeSource(dbtx walletdb.ReadWriteTx, // from the imported account, change addresses are created from // account 0. var ( - changeAddr btcutil.Address - err error + derivedAddr btcutil.Address + err error ) if account == waddrmgr.ImportedAddrAccount { - changeAddr, err = w.newChangeAddress( + derivedAddr, err = w.newChangeAddress( addrmgrNs, 0, *changeKeyScope, ) } else { - changeAddr, err = w.newChangeAddress( + derivedAddr, err = w.newChangeAddress( addrmgrNs, account, *changeKeyScope, ) } if err != nil { return nil, err } - return txscript.PayToAddrScript(changeAddr) + return txscript.PayToAddrScript(derivedAddr) } return addrmgrNs, &txauthor.ChangeSource{ diff --git a/wallet/createtx_test.go b/wallet/createtx_test.go index 6e6cbacc7d..e84ccdf88e 100644 --- a/wallet/createtx_test.go +++ b/wallet/createtx_test.go @@ -81,7 +81,7 @@ func TestTxToOutputsDryRun(t *testing.T) { // database us not inflated. dryRunTx, err := w.txToOutputs( txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, - nil, alwaysAllowUtxo, + nil, alwaysAllowUtxo, nil, ) if err != nil { t.Fatalf("unable to author tx: %v", err) @@ -99,7 +99,7 @@ func TestTxToOutputsDryRun(t *testing.T) { dryRunTx2, err := w.txToOutputs( txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, - nil, alwaysAllowUtxo, + nil, alwaysAllowUtxo, nil, ) if err != nil { t.Fatalf("unable to author tx: %v", err) @@ -135,7 +135,7 @@ func TestTxToOutputsDryRun(t *testing.T) { // to the database. tx, err := w.txToOutputs( txOuts, nil, nil, 0, 1, 1000, CoinSelectionLargest, false, - nil, alwaysAllowUtxo, + nil, alwaysAllowUtxo, nil, ) if err != nil { t.Fatalf("unable to author tx: %v", err) @@ -326,7 +326,7 @@ func TestTxToOutputsRandom(t *testing.T) { createTx := func() *txauthor.AuthoredTx { tx, err := w.txToOutputs( txOuts, nil, nil, 0, 1, feeSatPerKb, - CoinSelectionRandom, true, nil, alwaysAllowUtxo, + CoinSelectionRandom, true, nil, alwaysAllowUtxo, nil, ) require.NoError(t, err) return tx @@ -398,7 +398,7 @@ func TestCreateSimpleCustomChange(t *testing.T) { } tx1, err := w.txToOutputs( []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, 1000, - CoinSelectionLargest, true, nil, alwaysAllowUtxo, + CoinSelectionLargest, true, nil, alwaysAllowUtxo, nil, ) require.NoError(t, err) @@ -424,7 +424,7 @@ func TestCreateSimpleCustomChange(t *testing.T) { tx2, err := w.txToOutputs( []*wire.TxOut{targetTxOut}, &waddrmgr.KeyScopeBIP0086, &waddrmgr.KeyScopeBIP0084, 0, 1, 1000, CoinSelectionLargest, - true, nil, alwaysAllowUtxo, + true, nil, alwaysAllowUtxo, nil, ) require.NoError(t, err) @@ -446,6 +446,60 @@ func TestCreateSimpleCustomChange(t *testing.T) { } } +// TestTxToOutputsCustomChangeAddr tests that when a custom change address is +// passed to txToOutputs, the resulting transaction sends change to that address +// rather than a wallet-derived one. +func TestTxToOutputsCustomChangeAddr(t *testing.T) { + t.Parallel() + + w, cleanup := testWallet(t) + defer cleanup() + + // Fund the wallet with a P2WKH output. + p2wkhAddr, err := w.CurrentAddress(0, waddrmgr.KeyScopeBIP0084) + require.NoError(t, err) + + p2wkhScript, err := txscript.PayToAddrScript(p2wkhAddr) + require.NoError(t, err) + + const fundAmt = 2_000_000 + incomingTx := &wire.MsgTx{ + TxIn: []*wire.TxIn{{}}, + TxOut: []*wire.TxOut{wire.NewTxOut(fundAmt, p2wkhScript)}, + } + addUtxo(t, w, incomingTx) + + // Derive an external address to use as the custom change destination. + // This address is not wallet-managed — it simulates a user-supplied + // address. Must be a testnet address since testWallet uses TestNet3Params. + changeAddr, err := btcutil.DecodeAddress( + "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", + &chaincfg.TestNet3Params, + ) + require.NoError(t, err) + + changeScript, err := txscript.PayToAddrScript(changeAddr) + require.NoError(t, err) + + // Send 500_000 sats — less than the funded amount so a change output + // must be produced. + targetTxOut := &wire.TxOut{ + Value: 500_000, + PkScript: p2wkhScript, + } + tx, err := w.txToOutputs( + []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, 1000, + CoinSelectionLargest, true, nil, alwaysAllowUtxo, changeAddr, + ) + require.NoError(t, err) + require.NotEqual(t, tx.ChangeIndex, -1, "expected a change output") + + // The change output must use the caller-supplied script. + changeOut := tx.Tx.TxOut[tx.ChangeIndex] + require.Equal(t, changeScript, changeOut.PkScript, + "change output does not use the custom change address") +} + // TestSelectUtxosTxoToOutpoint tests that it is possible to use passed // selected utxos to craft a transaction in `txToOutpoint`. func TestSelectUtxosTxoToOutpoint(t *testing.T) { @@ -562,7 +616,7 @@ func TestSelectUtxosTxoToOutpoint(t *testing.T) { tx1, err := w.txToOutputs( []*wire.TxOut{targetTxOut}, nil, nil, 0, 1, 1000, CoinSelectionLargest, true, - tc.selectUTXOs, alwaysAllowUtxo, + tc.selectUTXOs, alwaysAllowUtxo, nil, ) if tc.errString != "" { require.ErrorContains(t, err, tc.errString) diff --git a/wallet/interface.go b/wallet/interface.go index 1b1a85646f..299d736e35 100644 --- a/wallet/interface.go +++ b/wallet/interface.go @@ -247,7 +247,8 @@ type Interface interface { SendOutputs(outputs []*wire.TxOut, coinSelectKeyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, - strategy CoinSelectionStrategy, label string) (*wire.MsgTx, error) + strategy CoinSelectionStrategy, label string, + optFuncs ...TxCreateOption) (*wire.MsgTx, error) // SendOutputsWithInput is a variant of SendOutputs that allows // specifying a particular input to use for the transaction. @@ -255,7 +256,8 @@ type Interface interface { coinSelectKeyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, strategy CoinSelectionStrategy, label string, - inputs []wire.OutPoint) (*wire.MsgTx, error) + inputs []wire.OutPoint, + optFuncs ...TxCreateOption) (*wire.MsgTx, error) // PublishTransaction broadcasts a transaction to the network. PublishTransaction(tx *wire.MsgTx, label string) error diff --git a/wallet/psbt.go b/wallet/psbt.go index 2151858954..efda52c299 100644 --- a/wallet/psbt.go +++ b/wallet/psbt.go @@ -49,6 +49,17 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, coinSelectionStrategy CoinSelectionStrategy, optFuncs ...TxCreateOption) (int32, error) { + // WithChangeAddress is not supported for PSBT funding. + // It requires the address to be wallet-owned. + opts := defaultTxCreateOptions() + for _, f := range optFuncs { + f(opts) + } + if opts.changeAddr != nil { + return 0, fmt.Errorf("WithChangeAddress is not supported for " + + "PSBT funding") + } + // Make sure the packet is well formed. We only require there to be at // least one input or output. err := psbt.VerifyInputOutputLen(packet, false, false) @@ -182,7 +193,7 @@ func (w *Wallet) FundPsbt(packet *psbt.Packet, keyScope *waddrmgr.KeyScope, // a new change address into the database. err = walletdb.Update(w.db, func(dbtx walletdb.ReadWriteTx) error { _, changeSource, err := w.addrMgrWithChangeSource( - dbtx, opts.changeKeyScope, account, + dbtx, opts.changeKeyScope, account, nil, ) if err != nil { return err diff --git a/wallet/wallet.go b/wallet/wallet.go index a30958a93b..57b3cd3da2 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -1214,6 +1214,7 @@ type ( resp chan createTxResponse selectUtxos []wire.OutPoint allowUtxo func(wtxmgr.Credit) bool + changeAddr btcutil.Address } createTxResponse struct { tx *txauthor.AuthoredTx @@ -1256,6 +1257,7 @@ out: txr.changeKeyScope, txr.account, txr.minconf, txr.feeSatPerKB, txr.coinSelectionStrategy, txr.dryRun, txr.selectUtxos, txr.allowUtxo, + txr.changeAddr, ) release() @@ -1274,6 +1276,7 @@ type txCreateOptions struct { changeKeyScope *waddrmgr.KeyScope selectUtxos []wire.OutPoint allowUtxo func(wtxmgr.Credit) bool + changeAddr btcutil.Address } // TxCreateOption is a set of optional arguments to modify the tx creation @@ -1313,6 +1316,15 @@ func WithUtxoFilter(allowUtxo func(utxo wtxmgr.Credit) bool) TxCreateOption { } } +// WithChangeAddress can be used to specify a custom address for the change +// output. If unspecified, a change address is derived from the wallet's HD +// tree using the configured change key scope. +func WithChangeAddress(addr btcutil.Address) TxCreateOption { + return func(opts *txCreateOptions) { + opts.changeAddr = addr + } +} + // CreateSimpleTx creates a new signed transaction spending unspent outputs with // at least minconf confirmations spending to any number of address/amount // pairs. Only unspent outputs belonging to the given key scope and account will @@ -1358,6 +1370,7 @@ func (w *Wallet) CreateSimpleTx(coinSelectKeyScope *waddrmgr.KeyScope, resp: make(chan createTxResponse), selectUtxos: opts.selectUtxos, allowUtxo: opts.allowUtxo, + changeAddr: opts.changeAddr, } w.createTxRequests <- req resp := <-req.resp @@ -3524,12 +3537,12 @@ func (w *Wallet) TotalReceivedForAddr(addr btcutil.Address, minConf int32) (btcu // returns the transaction upon success. func (w *Wallet) SendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, - coinSelectionStrategy CoinSelectionStrategy, label string) (*wire.MsgTx, - error) { + coinSelectionStrategy CoinSelectionStrategy, label string, + optFuncs ...TxCreateOption) (*wire.MsgTx, error) { return w.sendOutputs( outputs, keyScope, account, minconf, satPerKb, - coinSelectionStrategy, label, + coinSelectionStrategy, label, nil, optFuncs..., ) } @@ -3539,10 +3552,11 @@ func (w *Wallet) SendOutputsWithInput(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, coinSelectionStrategy CoinSelectionStrategy, label string, - selectedUtxos []wire.OutPoint) (*wire.MsgTx, error) { + selectedUtxos []wire.OutPoint, + optFuncs ...TxCreateOption) (*wire.MsgTx, error) { return w.sendOutputs(outputs, keyScope, account, minconf, satPerKb, - coinSelectionStrategy, label, selectedUtxos...) + coinSelectionStrategy, label, selectedUtxos, optFuncs...) } // sendOutputs creates and sends payment transactions. It returns the @@ -3550,7 +3564,8 @@ func (w *Wallet) SendOutputsWithInput(outputs []*wire.TxOut, func (w *Wallet) sendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, coinSelectionStrategy CoinSelectionStrategy, label string, - selectedUtxos ...wire.OutPoint) (*wire.MsgTx, error) { + selectedUtxos []wire.OutPoint, optFuncs ...TxCreateOption) (*wire.MsgTx, + error) { // Ensure the outputs to be created adhere to the network's consensus // rules. @@ -3563,15 +3578,18 @@ func (w *Wallet) sendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, } } + allOpts := append( + []TxCreateOption{WithCustomSelectUtxos(selectedUtxos)}, + optFuncs..., + ) + // Create the transaction and broadcast it to the network. The // transaction will be added to the database in order to ensure that we // continue to re-broadcast the transaction upon restarts until it has // been confirmed. createdTx, err := w.CreateSimpleTx( keyScope, account, outputs, minconf, satPerKb, - coinSelectionStrategy, false, WithCustomSelectUtxos( - selectedUtxos, - ), + coinSelectionStrategy, false, allOpts..., ) if err != nil { return nil, err