staticaddr/loopin: send per-deposit address proofs

Map every selected outpoint to the client key that derived its static
address and include those proofs in loop-in requests. Keep MuSig2
signing indexed by outpoint so request ordering cannot select the wrong
key.
This commit is contained in:
Slyghtning 2026-07-10 14:23:33 +02:00
parent 689e1b119f
commit b90c8fb386
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
6 changed files with 275 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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) (

View file

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