From 689e1b119f0414b74c2ebb5cfaf6d2a0e0a59a23 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:13 +0200 Subject: [PATCH] staticaddr: sign with per-deposit address keys Construct cooperative MuSig2 sessions from the address parameters stored on each deposit. Loop-ins and withdrawals can therefore combine inputs owned by different derived static addresses. --- staticaddr/loopin/actions.go | 9 +- staticaddr/loopin/loopin.go | 4 +- staticaddr/loopin/manager.go | 12 ++- staticaddr/staticutil/utils.go | 102 ++++++++++++++++----- staticaddr/staticutil/utils_test.go | 135 ++++++++++++++++++++++------ staticaddr/withdraw/manager.go | 33 +++---- 6 files changed, 216 insertions(+), 79 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 3dc1c029..78403d21 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -601,8 +601,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, // rates. createSession := staticutil.CreateMusig2Sessions htlcSessions, clientHtlcNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to create musig2 sessions: %w", err) @@ -612,8 +611,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessions) htlcSessionsHighFee, highFeeNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { return f.HandleError(err) @@ -621,8 +619,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, defer f.cleanUpSessions(ctx, htlcSessionsHighFee) htlcSessionsExtremelyHighFee, extremelyHighNonces, err := createSession( - ctx, f.cfg.Signer, f.loopIn.Deposits, f.loopIn.AddressParams, - f.loopIn.Address, + ctx, f.cfg.Signer, f.loopIn.Deposits, ) if err != nil { err = fmt.Errorf("unable to convert nonces: %w", err) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 7fcc3ff9..25bd004a 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -204,9 +204,7 @@ func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context, musig2sessions []*input.MuSig2SessionInfo, counterPartyNonces [][musig2.PubNonceSize]byte) ([][]byte, error) { - prevOuts, err := staticutil.ToPrevOuts( - l.Deposits, l.AddressParams.PkScript, - ) + prevOuts, err := staticutil.ToPrevOuts(l.Deposits) if err != nil { return nil, err } diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 47a447fc..2f1e0e77 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -376,8 +376,18 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, map[string]*swapserverrpc.ClientSweeplessSigningInfo, len(req.DepositToNonces), ) + depositMap := make(map[string]*deposit.Deposit, len(loopIn.Deposits)) + for _, d := range loopIn.Deposits { + depositMap[d.String()] = d + } for depositOutpoint, nonce := range req.DepositToNonces { + d, ok := depositMap[depositOutpoint] + if !ok { + return fmt.Errorf("deposit %v not found in loop-in", + depositOutpoint) + } + taprootSigHash, err := txscript.CalcTaprootSignatureHash( sigHashes, txscript.SigHashDefault, sweepPacket.UnsignedTx, @@ -396,7 +406,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, } musig2Session, err := staticutil.CreateMusig2Session( - ctx, m.cfg.Signer, loopIn.AddressParams, loopIn.Address, + ctx, m.cfg.Signer, d, ) if err != nil { return err diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index a2509333..17da0656 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -3,6 +3,7 @@ package staticutil import ( "bytes" "context" + "errors" "fmt" "sort" @@ -12,7 +13,6 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" @@ -21,8 +21,12 @@ import ( ) // ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts. -func ToPrevOuts(deposits []*deposit.Deposit, - pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { +// +// Each deposit carries the static address parameters that produced its output. +// Using the per-deposit script here keeps signing correct when one transaction +// spends deposits from multiple static addresses. +func ToPrevOuts(deposits []*deposit.Deposit) ( + map[wire.OutPoint]*wire.TxOut, error) { outpoints := make([]wire.OutPoint, len(deposits)) for i, d := range deposits { @@ -35,9 +39,13 @@ func ToPrevOuts(deposits []*deposit.Deposit, prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) for i, d := range deposits { outpoint := outpoints[i] + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } txOut := &wire.TxOut{ Value: int64(d.Value), - PkScript: pkScript, + PkScript: d.AddressParams.PkScript, } prevOuts[outpoint] = txOut } @@ -47,9 +55,8 @@ func ToPrevOuts(deposits []*deposit.Deposit, // CreateMusig2Sessions creates a musig2 session for a number of deposits. func CreateMusig2Sessions(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *script.Parameters, - staticAddress *script.StaticAddress) ([]*input.MuSig2SessionInfo, + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( + []*input.MuSig2SessionInfo, [][]byte, error) { musig2Sessions := make([]*input.MuSig2SessionInfo, len(deposits)) @@ -58,7 +65,7 @@ func CreateMusig2Sessions(ctx context.Context, // Create the sessions and nonces from the deposits. for i := range len(deposits) { session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposits[i], ) if err != nil { return nil, nil, err @@ -72,11 +79,12 @@ func CreateMusig2Sessions(ctx context.Context, } // CreateMusig2SessionsPerDeposit creates a musig2 session for a number of -// deposits. +// deposits and returns the sessions keyed by outpoint string. +// +// The per-deposit keying mirrors the server response format and avoids relying +// on positional ordering after the request crosses the wire. func CreateMusig2SessionsPerDeposit(ctx context.Context, - signer lndclient.SignerClient, deposits []*deposit.Deposit, - addrParams *script.Parameters, - staticAddress *script.StaticAddress) ( + signer lndclient.SignerClient, deposits []*deposit.Deposit) ( map[string]*input.MuSig2SessionInfo, map[string][]byte, map[string]int, error) { @@ -86,25 +94,73 @@ func CreateMusig2SessionsPerDeposit(ctx context.Context, // Create the musig2 sessions for the sweepless sweep tx. for i, deposit := range deposits { - session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, - ) - if err != nil { - return nil, nil, nil, err + depositKey := deposit.String() + if _, ok := sessions[depositKey]; ok { + err := fmt.Errorf("duplicate outpoint %v", depositKey) + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) } - sessions[deposit.String()] = session - nonces[deposit.String()] = session.PublicNonce[:] - depositToIdx[deposit.String()] = i + session, err := CreateMusig2Session( + ctx, signer, deposit, + ) + if err != nil { + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) + } + + sessions[depositKey] = session + nonces[depositKey] = session.PublicNonce[:] + depositToIdx[depositKey] = i } return sessions, nonces, depositToIdx, nil } -// CreateMusig2Session creates a musig2 session for the deposit. +// CleanupMusig2Sessions releases all supplied MuSig2 sessions. +func CleanupMusig2Sessions(ctx context.Context, + signer lndclient.SignerClient, + sessions map[string]*input.MuSig2SessionInfo) error { + + var cleanupErr error + for depositKey, session := range sessions { + if session == nil { + continue + } + + err := signer.MuSig2Cleanup( + context.WithoutCancel(ctx), session.SessionID, + ) + if err != nil { + cleanupErr = errors.Join( + cleanupErr, fmt.Errorf("unable to clean up MuSig2 "+ + "session for deposit %v: %w", depositKey, err), + ) + } + } + + return cleanupErr +} + +// CreateMusig2Session creates a musig2 session for the deposit's static +// address. func CreateMusig2Session(ctx context.Context, - signer lndclient.SignerClient, addrParams *script.Parameters, - staticAddress *script.StaticAddress) (*input.MuSig2SessionInfo, error) { + signer lndclient.SignerClient, d *deposit.Deposit) ( + *input.MuSig2SessionInfo, error) { + + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", d.OutPoint) + } + + staticAddress, err := d.GetStaticAddressScript() + if err != nil { + return nil, err + } + + addrParams := d.AddressParams signers := [][]byte{ addrParams.ClientPubkey.SerializeCompressed(), diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index aaed343e..4f32eade 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -3,14 +3,16 @@ package staticutil import ( "bytes" "context" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" looptest "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" @@ -21,6 +23,37 @@ import ( "github.com/stretchr/testify/require" ) +type sessionCleanupSigner struct { + lndclient.SignerClient + + createCalls int + failCreateAt int + cleaned [][32]byte + cleanupCtxErr []error +} + +func (s *sessionCleanupSigner) MuSig2CreateSession(context.Context, + input.MuSig2Version, *keychain.KeyLocator, [][]byte, + ...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) { + + s.createCalls++ + if s.createCalls == s.failCreateAt { + return nil, errors.New("session creation failed") + } + + sessionID := [32]byte{byte(s.createCalls)} + return &input.MuSig2SessionInfo{SessionID: sessionID}, nil +} + +func (s *sessionCleanupSigner) MuSig2Cleanup(ctx context.Context, + sessionID [32]byte) error { + + s.cleaned = append(s.cleaned, sessionID) + s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err()) + + return nil +} + // mustHash converts a hex string to a chainhash.Hash and panics on error. func mustHash(t *testing.T, s string) chainhash.Hash { t.Helper() @@ -36,7 +69,8 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "0000000000000000000000000000000000000000000000000000000000000001"), Index: 0, }, - Value: btcutil.Amount(12345), + Value: btcutil.Amount(12345), + AddressParams: &address.Parameters{PkScript: []byte{0x51}}, } d2 := &deposit.Deposit{ @@ -44,12 +78,11 @@ func TestToPrevOuts_Success(t *testing.T) { Hash: mustHash(t, "1111111111111111111111111111111111111111111111111111111111111111"), Index: 7, }, - Value: btcutil.Amount(987654321), + Value: btcutil.Amount(987654321), + AddressParams: &address.Parameters{PkScript: []byte{0x52}}, } - pkScript := []byte{0x51, 0x21, 0x02, 0x52} // arbitrary bytes - - prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, pkScript) + prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.NoError(t, err) // We expect two entries. @@ -59,13 +92,13 @@ func TestToPrevOuts_Success(t *testing.T) { txOut1, ok := prevOuts[d1.OutPoint] require.True(t, ok, "expected outpoint d1 to be present") require.EqualValues(t, int64(d1.Value), txOut1.Value) - require.Equal(t, pkScript, txOut1.PkScript) + require.Equal(t, d1.AddressParams.PkScript, txOut1.PkScript) // Check the second outpoint mapping. txOut2, ok := prevOuts[d2.OutPoint] require.True(t, ok, "expected outpoint d2 to be present") require.EqualValues(t, int64(d2.Value), txOut2.Value) - require.Equal(t, pkScript, txOut2.PkScript) + require.Equal(t, d2.AddressParams.PkScript, txOut2.PkScript) // Ensure the keys in the map are exactly the outpoints we provided. for op := range prevOuts { @@ -80,13 +113,34 @@ func TestToPrevOuts_DuplicateOutpoint(t *testing.T) { Index: 2, } - d1 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(100)} - d2 := &deposit.Deposit{OutPoint: shared, Value: btcutil.Amount(200)} + d1 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(100), + AddressParams: &address.Parameters{PkScript: []byte{0x00}}, + } + d2 := &deposit.Deposit{ + OutPoint: shared, + Value: btcutil.Amount(200), + AddressParams: &address.Parameters{PkScript: []byte{0x01}}, + } - _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}, []byte{0x00}) + _, err := ToPrevOuts([]*deposit.Deposit{d1, d2}) require.Error(t, err) } +func TestToPrevOutsMissingAddressParams(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "3333333333333333333333333333333333333333333333333333333333333333"), + Index: 3, + }, + Value: btcutil.Amount(100), + } + + _, err := ToPrevOuts([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { @@ -182,13 +236,8 @@ func TestCreateMusig2Session_Success(t *testing.T) { KeyLocator: keychain.KeyLocator{Family: 1, Index: 2}, } - // Build a static address for tweak options. - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) - require.NoError(t, err) - - sess, err := CreateMusig2Session(context.Background(), signer, params, staticAddr) + d := &deposit.Deposit{AddressParams: params} + sess, err := CreateMusig2Session(context.Background(), signer, d) require.NoError(t, err) require.NotNil(t, sess) } @@ -211,20 +260,15 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, } - staticAddr, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), params.ClientPubkey, params.ServerPubkey, - ) - require.NoError(t, err) - // Prepare N deposits; only the length matters for session count. deposits := []*deposit.Deposit{ - {OutPoint: wire.OutPoint{Index: 0}}, - {OutPoint: wire.OutPoint{Index: 1}}, - {OutPoint: wire.OutPoint{Index: 2}}, + {OutPoint: wire.OutPoint{Index: 0}, AddressParams: params}, + {OutPoint: wire.OutPoint{Index: 1}, AddressParams: params}, + {OutPoint: wire.OutPoint{Index: 2}, AddressParams: params}, } sessions, nonces, err := CreateMusig2Sessions( - context.Background(), signer, deposits, params, staticAddr, + context.Background(), signer, deposits, ) require.NoError(t, err) require.Len(t, sessions, len(deposits)) @@ -237,6 +281,43 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { } } +func TestCreateMusig2SessionsPerDepositCleansUpPartialFailure( + t *testing.T) { + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Expiry: 12, + KeyLocator: keychain.KeyLocator{Family: 9, Index: 8}, + } + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: params, + }, + { + OutPoint: wire.OutPoint{Index: 2}, + AddressParams: params, + }, + } + + signer := &sessionCleanupSigner{failCreateAt: 2} + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, _, _, err = CreateMusig2SessionsPerDeposit( + ctx, signer, deposits, + ) + require.ErrorContains(t, err, "session creation failed") + require.Equal(t, [][32]byte{{1}}, signer.cleaned) + require.Equal(t, []error{nil}, signer.cleanupCtxErr) +} + // makeDeposit creates a deposit with the given value for testing. func makeDeposit(value btcutil.Amount) *deposit.Deposit { return &deposit.Deposit{Value: value} diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 3a7927a4..002ee25c 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -536,32 +536,27 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, selectedWithdrawalAmount int64, commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { - // Create a musig2 session for each deposit. - addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, nil, err - } - - staticAddress, err := m.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return nil, nil, err - } - + // Create a musig2 session for each deposit. Each selected deposit carries + // the address parameters that produced the output, so withdrawals can + // spend inputs from multiple static addresses in one transaction. sessions, clientNonces, idx, err := staticutil.CreateMusig2SessionsPerDeposit( - ctx, m.cfg.Signer, deposits, addrParams, staticAddress, + ctx, m.cfg.Signer, deposits, ) if err != nil { return nil, nil, err } - - params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, nil, fmt.Errorf("couldn't get confirmation "+ - "height for deposit, %w", err) - } + defer func() { + err := staticutil.CleanupMusig2Sessions( + ctx, m.cfg.Signer, sessions, + ) + if err != nil { + log.Warnf("Unable to clean up withdrawal MuSig2 "+ + "sessions: %v", err) + } + }() outpoints := toOutpoints(deposits) - prevOuts, err := staticutil.ToPrevOuts(deposits, params.PkScript) + prevOuts, err := staticutil.ToPrevOuts(deposits) if err != nil { return nil, nil, err }