diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 0f32697a..b2698fc4 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" ) @@ -18,6 +19,10 @@ type AddressManager interface { // GetStaticAddress returns the deposit address for the given // client and server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) + + // NewChangeAddress derives and persists a fresh static address from the + // change key family for this operation's change output. + NewChangeAddress(ctx context.Context) (*address.Parameters, error) } type DepositManager interface { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index e113f794..b10582dd 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -9,7 +9,6 @@ import ( "sync" "sync/atomic" - "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" @@ -19,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" staticaddressrpc "github.com/lightninglabs/loop/swapserverrpc" @@ -280,8 +280,7 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { } err = m.handleWithdrawal( - ctx, deposits, tx.TxHash(), - tx.TxOut[0].PkScript, + ctx, deposits, tx.TxHash(), tx.TxOut[0].PkScript, ) if err != nil { return err @@ -567,10 +566,28 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, "input proofs: %w", err) } + _, changeAmount, err := CalculateWithdrawalTxValues( + deposits, btcutil.Amount(selectedWithdrawalAmount), feeRate, + withdrawalAddress, commitmentType, + ) + if err != nil { + return nil, nil, fmt.Errorf("error calculating funding tx "+ + "values: %w", err) + } + + var changeParams *address.Parameters + if changeAmount > 0 { + changeParams, err = m.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + return nil, nil, fmt.Errorf("unable to create static "+ + "address change output: %w", err) + } + } + withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( - ctx, outpoints, deposits, prevOuts, + outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, - feeRate, commitmentType, + feeRate, commitmentType, changeParams, ) if err != nil { return nil, nil, err @@ -578,10 +595,10 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // Request the server to sign the withdrawal transaction. // - // The withdrawal and change amount are sent to the server with the - // expectation that the server just signs the transaction, without - // performing fee calculations and dust considerations. The client is - // responsible for that. + // All withdrawal outputs, including any change output, are encoded in + // the PSBT. The server signs the transaction as constructed without + // performing fee calculations or dust handling. The client is + // responsible for both. // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ @@ -666,22 +683,56 @@ func (m *Manager) publishFinalizedWithdrawalTx(ctx context.Context, return true, nil } +func withdrawalChangePkScript(tx *wire.MsgTx) []byte { + if tx == nil || len(tx.TxOut) < 2 { + return nil + } + + return tx.TxOut[1].PkScript +} + +// validateConfirmedWithdrawalInputs verifies that a confirmed withdrawal +// transaction spends every deposit associated with the withdrawal. Withdrawal +// monitoring intentionally watches only the first deposit so an RBF replacement +// can be discovered without registering a new confirmation notification for +// every replacement transaction. However, the spend notification also fires if +// an unrelated transaction spends only that first deposit. Requiring the full +// deposit set prevents such a partial spend from incorrectly transitioning all +// deposits to Withdrawn while still permitting a replacement transaction with a +// different transaction ID or additional inputs. +func validateConfirmedWithdrawalInputs(tx *wire.MsgTx, + deposits []*deposit.Deposit) error { + + inputs := make(map[wire.OutPoint]struct{}, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + inputs[txIn.PreviousOutPoint] = struct{}{} + } + + for _, d := range deposits { + if _, ok := inputs[d.OutPoint]; !ok { + return fmt.Errorf("confirmed transaction %v does not spend "+ + "withdrawal deposit %v", tx.TxHash(), d.OutPoint) + } + } + + return nil +} + // handleWithdrawal starts a goroutine that listens for the spent of the first // input of the withdrawal transaction. func (m *Manager) handleWithdrawal(ctx context.Context, - deposits []*deposit.Deposit, txHash chainhash.Hash, - withdrawalPkscript []byte) error { - - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - log.Errorf("error retrieving address params: %v", err) - - return fmt.Errorf("withdrawal failed") - } + deposits []*deposit.Deposit, originalTxHash chainhash.Hash, + withdrawalPkScript []byte) error { d := deposits[0] + if d.AddressParams == nil { + return fmt.Errorf("missing static address parameters for %v", + d.OutPoint) + } + depositPkScript := d.AddressParams.PkScript + spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn( - ctx, &d.OutPoint, addrParams.PkScript, + ctx, &d.OutPoint, depositPkScript, int32(d.GetConfirmationHeight()), ) if err != nil { @@ -692,13 +743,20 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case spentTx := <-spentChan: spendingHeight := uint32(spentTx.SpendingHeight) + spenderTxHash := originalTxHash + if spentTx.SpenderTxHash != nil { + spenderTxHash = *spentTx.SpenderTxHash + } else if spentTx.SpendingTx != nil { + spenderTxHash = spentTx.SpendingTx.TxHash() + } + // If the transaction received one confirmation, we // ensure re-org safety by waiting for some more // confirmations. confChan, confErrChan, err := m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, spentTx.SpenderTxHash, - withdrawalPkscript, MinConfs, + ctx, &spenderTxHash, withdrawalPkScript, + MinConfs, int32(m.initiationHeight.Load()), ) if err != nil { @@ -712,6 +770,32 @@ func (m *Manager) handleWithdrawal(ctx context.Context, select { case tx := <-confChan: + confirmedTx := spentTx.SpendingTx + if tx != nil && tx.Tx != nil { + confirmedTx = tx.Tx + } + if confirmedTx == nil { + log.Errorf("Confirmed withdrawal %v "+ + "missing transaction", + spenderTxHash) + + return + } + + // Since the spend notification above only watches the + // first deposit, verify that the confirmed spender is the + // withdrawal (or one of its RBF replacements) before + // transitioning the complete deposit group. + err = validateConfirmedWithdrawalInputs( + confirmedTx, deposits, + ) + if err != nil { + log.Errorf("Ignoring incomplete withdrawal: %v", + err) + + return + } + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -725,13 +809,14 @@ func (m *Manager) handleWithdrawal(ctx context.Context, // withdrawals to stop republishing it on block // arrivals. m.mu.Lock() - delete(m.finalizedWithdrawalTxns, txHash) + delete(m.finalizedWithdrawalTxns, originalTxHash) + delete(m.finalizedWithdrawalTxns, spenderTxHash) m.mu.Unlock() // Persist info about the finalized withdrawal. err = m.cfg.Store.UpdateWithdrawal( - ctx, deposits, tx.Tx, spendingHeight, - addrParams.PkScript, + ctx, deposits, confirmedTx, spendingHeight, + withdrawalChangePkScript(confirmedTx), ) if err != nil { log.Errorf("Error persisting "+ @@ -888,12 +973,13 @@ func (m *Manager) signMusig2Tx(ctx context.Context, return tx, nil } -func (m *Manager) createWithdrawalTx(ctx context.Context, +func (m *Manager) createWithdrawalTx( outpoints []wire.OutPoint, deposits []*deposit.Deposit, prevOuts map[wire.OutPoint]*wire.TxOut, selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address, feeRate chainfee.SatPerKWeight, - commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { + commitmentType lnrpc.CommitmentType, + changeParams *address.Parameters) (*wire.MsgTx, []byte, error) { // First Create the tx. msgTx := wire.NewMsgTx(2) @@ -940,30 +1026,14 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, }) if changeAmount > 0 { - // Send change back to the same static address. - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - log.Errorf("error retrieving taproot address %v", err) - - return nil, nil, fmt.Errorf("withdrawal failed") - } - - changeAddress, err := btcutil.NewAddressTaproot( - schnorr.SerializePubKey(staticAddress.TaprootKey), - m.cfg.ChainParams, - ) - if err != nil { - return nil, nil, err - } - - changeScript, err := txscript.PayToAddrScript(changeAddress) - if err != nil { - return nil, nil, err + if changeParams == nil { + return nil, nil, fmt.Errorf("missing static address " + + "change parameters") } msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: changeScript, + PkScript: changeParams.PkScript, }) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 6c883a7c..d3b7ab7a 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -3,24 +3,54 @@ package withdraw import ( "context" "testing" + "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) +type withdrawalCleanupSigner struct { + lndclient.SignerClient + + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *withdrawalCleanupSigner) MuSig2CreateSession(context.Context, + input.MuSig2Version, *keychain.KeyLocator, [][]byte, + ...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) { + + return &input.MuSig2SessionInfo{SessionID: [32]byte{1}}, nil +} + +func (s *withdrawalCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // TestNewManagerHeightValidation ensures the constructor rejects zero heights. func TestNewManagerHeightValidation(t *testing.T) { t.Parallel() @@ -35,6 +65,202 @@ func TestNewManagerHeightValidation(t *testing.T) { require.NotNil(t, manager) } +func TestWithdrawalChangePkScript(t *testing.T) { + t.Parallel() + + require.Nil(t, withdrawalChangePkScript(nil)) + + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: []byte{0x01}, + }) + require.Nil(t, withdrawalChangePkScript(tx)) + + tx.AddTxOut(&wire.TxOut{ + Value: 500, + PkScript: []byte{0x02}, + }) + require.Equal(t, []byte{0x02}, withdrawalChangePkScript(tx)) +} + +// TestValidateConfirmedWithdrawalInputs verifies that a replacement +// transaction must preserve the complete withdrawal deposit set. Additional +// inputs are allowed because they do not change which deposits are withdrawn. +func TestValidateConfirmedWithdrawalInputs(t *testing.T) { + t.Parallel() + + first := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{1}, Index: 1}, + } + second := &deposit.Deposit{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{2}, Index: 2}, + } + deposits := []*deposit.Deposit{first, second} + + partialSpend := wire.NewMsgTx(2) + partialSpend.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + err := validateConfirmedWithdrawalInputs(partialSpend, deposits) + require.ErrorContains(t, err, second.OutPoint.String()) + + replacement := wire.NewMsgTx(2) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: first.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: second.OutPoint, + }) + replacement.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, Index: 3, + }, + }) + require.NoError( + t, validateConfirmedWithdrawalInputs(replacement, deposits), + ) +} + +func TestCreateFinalizedWithdrawalTxCleansUpSessionsOnError(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + signer := &withdrawalCleanupSigner{} + manager := &Manager{cfg: &ManagerConfig{Signer: signer}} + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{Index: 1}, + Value: 100_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 144, + PkScript: []byte{0x51}, + KeyLocator: keychain.KeyLocator{ + Family: 1, + Index: 2, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, _, err = manager.CreateFinalizedWithdrawalTx( + ctx, deposits, nil, 1_000, 0, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + require.ErrorContains( + t, err, "either address or commitment type must be specified", + ) + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + +type withdrawalConfRegistration struct { + txID *chainhash.Hash + pkScript []byte + numConfs int32 + heightHint int32 +} + +type withdrawalTestNotifier struct { + lndclient.ChainNotifierClient + + spendChan chan *chainntnfs.SpendDetail + spendErr chan error + confChan chan *chainntnfs.TxConfirmation + confErr chan error + confReq chan withdrawalConfRegistration +} + +func newWithdrawalTestNotifier() *withdrawalTestNotifier { + return &withdrawalTestNotifier{ + spendChan: make(chan *chainntnfs.SpendDetail, 1), + spendErr: make(chan error, 1), + confChan: make(chan *chainntnfs.TxConfirmation, 1), + confErr: make(chan error, 1), + confReq: make(chan withdrawalConfRegistration, 1), + } +} + +func (n *withdrawalTestNotifier) RegisterSpendNtfn(context.Context, + *wire.OutPoint, []byte, int32, ...lndclient.NotifierOption) ( + chan *chainntnfs.SpendDetail, chan error, error) { + + return n.spendChan, n.spendErr, nil +} + +func (n *withdrawalTestNotifier) RegisterConfirmationsNtfn(_ context.Context, + txid *chainhash.Hash, pkScript []byte, numConfs, heightHint int32, + _ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation, + chan error, error) { + + n.confReq <- withdrawalConfRegistration{ + txID: txid, + pkScript: pkScript, + numConfs: numConfs, + heightHint: heightHint, + } + + return n.confChan, n.confErr, nil +} + +func TestHandleWithdrawalFollowsReplacementTxid(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + UseLogger(btclog.Disabled) + + notifier := newWithdrawalTestNotifier() + manager, err := NewManager(&ManagerConfig{ + ChainNotifier: notifier, + }, 123) + require.NoError(t, err) + + originalTxHash := chainhash.Hash{1} + replacementTxHash := chainhash.Hash{2} + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + ConfirmationHeight: 42, + AddressParams: &address.Parameters{ + PkScript: []byte{0x51}, + }, + } + manager.finalizedWithdrawalTxns[originalTxHash] = wire.NewMsgTx(2) + withdrawalPkScript := []byte{0x51} + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, originalTxHash, + withdrawalPkScript, + ) + require.NoError(t, err) + + notifier.spendChan <- &chainntnfs.SpendDetail{ + SpenderTxHash: &replacementTxHash, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 50, + } + + select { + case req := <-notifier.confReq: + require.NotNil(t, req.txID) + require.Equal(t, replacementTxHash, *req.txID) + require.Equal(t, withdrawalPkScript, req.pkScript) + require.Equal(t, MinConfs, req.numConfs) + require.EqualValues(t, 123, req.heightHint) + + case <-ctx.Done(): + t.Fatalf("confirmation registration not received: %v", ctx.Err()) + } +} + // TestSignMusig2Tx_MissingSigningInfo tests that signMusig2Tx should error // when sigInfo is missing an entry for one of the deposits. //