diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 78403d21..3c828b1b 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -158,16 +158,27 @@ func (f *FSM) InitHtlcAction(ctx context.Context, version.CurrentRPCProtocolVersion(), ) + depositClientPubkeys, err := staticutil.DepositClientPubkeys( + f.loopIn.Deposits, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address input "+ + "proofs: %w", err) + + return returnError(err) + } + loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{ - SwapHash: f.loopIn.SwapHash[:], - DepositOutpoints: f.loopIn.DepositOutpoints, - Amount: uint64(f.loopIn.SelectedAmount), - HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), - SwapInvoice: f.loopIn.SwapInvoice, - ProtocolVersion: version.CurrentRPCProtocolVersion(), - UserAgent: loop.UserAgent(f.loopIn.Initiator), - PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, - Fast: f.loopIn.Fast, + SwapHash: f.loopIn.SwapHash[:], + DepositOutpoints: f.loopIn.DepositOutpoints, + Amount: uint64(f.loopIn.SelectedAmount), + HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(), + SwapInvoice: f.loopIn.SwapInvoice, + ProtocolVersion: version.CurrentRPCProtocolVersion(), + UserAgent: loop.UserAgent(f.loopIn.Initiator), + PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, + Fast: f.loopIn.Fast, + DepositToClientPubkeys: depositClientPubkeys, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index afc0085d..3d2319f2 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -757,6 +757,7 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() + _, clientPubkey := test.CreateKey(20) _, serverKey := test.CreateKey(21) server := &mockStaticAddressServer{ @@ -771,6 +772,10 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + }, } loopIn := &StaticAddressLoopIn{ @@ -804,6 +809,17 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { require.Equal(t, OnHtlcInitiated, event) require.Nil(t, f.LastActionError) require.NotNil(t, server.request) + require.EqualValues( + t, swap.StaticAddressKeyFamily, loopIn.HtlcKeyLocator.Family, + ) + require.Equal( + t, clientPubkey.SerializeCompressed(), + server.request.DepositToClientPubkeys[dep.String()].GetPubkey(), + ) + require.Equal( + t, dep.AddressParams.PkScript, + server.request.DepositToClientPubkeys[dep.String()].GetPkScript(), + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, @@ -3142,10 +3158,15 @@ func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { defer cancel() mockLnd := test.NewMockLnd() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) loopIn := &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{{ Value: 200_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, }}, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), @@ -3192,12 +3213,17 @@ func TestInitHtlcActionCancelsInvoiceOnFeeGuardFailure(t *testing.T) { defer cancel() mockLnd := test.NewMockLnd() + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) loopIn := &StaticAddressLoopIn{ Deposits: []*deposit.Deposit{{ Value: 200_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, }}, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 25bd004a..d8710b1c 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -211,10 +211,29 @@ func (l *StaticAddressLoopIn) signMusig2Tx(ctx context.Context, prevOutFetcher := txscript.NewMultiPrevOutFetcher(prevOuts) outpoints := l.Outpoints() + if len(tx.TxIn) != len(outpoints) { + return nil, fmt.Errorf("htlc tx input count %d does not "+ + "match deposits %d", len(tx.TxIn), len(outpoints)) + } + if len(musig2sessions) != len(outpoints) { + return nil, fmt.Errorf("musig2 session count %d does not "+ + "match deposits %d", len(musig2sessions), len(outpoints)) + } + if len(counterPartyNonces) != len(outpoints) { + return nil, fmt.Errorf("server nonce count %d does not "+ + "match deposits %d", len(counterPartyNonces), + len(outpoints)) + } + sigHashes := txscript.NewTxSigHashes(tx, prevOutFetcher) sigs := make([][]byte, len(outpoints)) for idx, outpoint := range outpoints { + if musig2sessions[idx] == nil { + return nil, fmt.Errorf("missing musig2 session for "+ + "deposit input %d", idx) + } + if !reflect.DeepEqual(tx.TxIn[idx].PreviousOutPoint, outpoint) { diff --git a/staticaddr/loopin/sign_musig_test.go b/staticaddr/loopin/sign_musig_test.go new file mode 100644 index 00000000..c3915f8b --- /dev/null +++ b/staticaddr/loopin/sign_musig_test.go @@ -0,0 +1,71 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestSignMusig2TxRejectsNonceCountMismatch verifies malformed server nonce +// sets fail cleanly instead of panicking when signing HTLC variants. +func TestSignMusig2TxRejectsNonceCountMismatch(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + network := &chaincfg.RegressionNetParams + staticAddr, err := newStaticAddress( + clientKey.PubKey(), serverKey.PubKey(), 4032, + ) + require.NoError(t, err) + + pkScript, err := staticAddr.StaticAddressScript() + require.NoError(t, err) + + addrParams := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PkScript: pkScript, + Expiry: 4032, + ProtocolVersion: version.ProtocolVersion_V0, + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0xdd}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{4, 5, 6}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + } + + htlcTx, err := loopIn.createHtlcTx(network, loopIn.HtlcTxFeeRate, 1) + require.NoError(t, err) + + _, err = loopIn.signMusig2Tx( + t.Context(), htlcTx, &noopSigner{}, + []*input.MuSig2SessionInfo{{}}, nil, + ) + require.ErrorContains(t, err, "server nonce count") +} diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 17da0656..73fb97b4 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -53,6 +53,49 @@ func ToPrevOuts(deposits []*deposit.Deposit) ( return prevOuts, nil } +// DepositClientPubkeys maps each deposit outpoint to the static address +// descriptor that derives that output. +// +// The server receives this proof material with swap and withdrawal requests and +// verifies it against the L402's server key and expiry before co-signing any +// input. +func DepositClientPubkeys(deposits []*deposit.Deposit) ( + map[string]*swapserverrpc.StaticAddressDescriptor, error) { + + clientPubkeys := make( + map[string]*swapserverrpc.StaticAddressDescriptor, len(deposits), + ) + for _, d := range deposits { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for deposit %v", d.OutPoint) + } + if d.AddressParams.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address client "+ + "pubkey for deposit %v", d.OutPoint) + } + if len(d.AddressParams.PkScript) == 0 { + return nil, fmt.Errorf("missing static address pkscript "+ + "for deposit %v", d.OutPoint) + } + + depositKey := d.String() + if _, ok := clientPubkeys[depositKey]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + depositKey) + } + + clientPubkeys[depositKey] = + &swapserverrpc.StaticAddressDescriptor{ + Pubkey: d.AddressParams.ClientPubkey. + SerializeCompressed(), + PkScript: d.AddressParams.PkScript, + } + } + + return clientPubkeys, nil +} + // CreateMusig2Sessions creates a musig2 session for a number of deposits. func CreateMusig2Sessions(ctx context.Context, signer lndclient.SignerClient, deposits []*deposit.Deposit) ( diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index 4f32eade..2320eacb 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -141,6 +141,102 @@ func TestToPrevOutsMissingAddressParams(t *testing.T) { require.ErrorContains(t, err, "missing static address parameters") } +func TestDepositClientPubkeys(t *testing.T) { + clientKey1, err := btcec.NewPrivateKey() + require.NoError(t, err) + clientKey2, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d1 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "4444444444444444444444444444444444444444444444444444444444444444"), + Index: 0, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey1.PubKey(), + PkScript: []byte{0x51, 0x20, 0x01}, + }, + } + d2 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey2.PubKey(), + PkScript: []byte{0x51, 0x20, 0x02}, + }, + } + + proofs, err := DepositClientPubkeys([]*deposit.Deposit{d1, d2}) + require.NoError(t, err) + require.Equal( + t, clientKey1.PubKey().SerializeCompressed(), + proofs[d1.String()].GetPubkey(), + ) + require.Equal( + t, d1.AddressParams.PkScript, + proofs[d1.String()].GetPkScript(), + ) + require.Equal( + t, clientKey2.PubKey().SerializeCompressed(), + proofs[d2.String()].GetPubkey(), + ) + require.Equal( + t, d2.AddressParams.PkScript, + proofs[d2.String()].GetPkScript(), + ) +} + +func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) { + t.Run("missing params", func(t *testing.T) { + d := &deposit.Deposit{OutPoint: wire.OutPoint{Index: 1}} + _, err := DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address parameters") + }) + + t.Run("missing client key", func(t *testing.T) { + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &address.Parameters{}, + } + _, err := DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address client pubkey") + }) + + t.Run("duplicate outpoint", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "6666666666666666666666666666666666666666666666666666666666666666"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51, 0x20, 0x03}, + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d, d}) + require.ErrorContains(t, err, "duplicate outpoint") + }) + + t.Run("missing pkscript", func(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + d := &deposit.Deposit{ + OutPoint: wire.OutPoint{Index: 1}, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d}) + require.ErrorContains(t, err, "missing static address pkscript") + }) +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash {