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.
This commit is contained in:
Slyghtning 2026-07-10 14:23:13 +02:00
parent fd1c651a47
commit 689e1b119f
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
6 changed files with 216 additions and 79 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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