staticaddr/withdraw: use generated change addresses

Create a fresh static address for partial-withdrawal change. Keep all
withdrawal outputs in the PSBT without separate signing metadata, while
preserving full-withdrawal behavior.
This commit is contained in:
Slyghtning 2026-07-10 14:23:57 +02:00
parent dc42446bae
commit 1042f3bf19
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
3 changed files with 347 additions and 46 deletions

View file

@ -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 {

View file

@ -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,
})
}

View file

@ -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.
//