From 41f5dcf9bd5c28365b58aa257a708dba0ec8309f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 14:13:00 +0200 Subject: [PATCH 01/35] staticaddr/deposit: track unconfirmed deposits Retain static-address deposits as soon as lnd reports the UTXO, even when the output is still unconfirmed. Store the first confirmation height once the output confirms. Derive confirmation heights from the current wallet view because lnd reports confirmation counts instead of first-confirmation heights. --- staticaddr/deposit/deposit.go | 7 +- staticaddr/deposit/deposit_test.go | 17 + staticaddr/deposit/fsm.go | 8 +- staticaddr/deposit/manager.go | 138 +++++++-- staticaddr/deposit/manager_height_test.go | 37 +++ staticaddr/deposit/manager_reconcile_test.go | 307 +++++++++++++++++++ staticaddr/deposit/manager_test.go | 24 +- 7 files changed, 497 insertions(+), 41 deletions(-) create mode 100644 staticaddr/deposit/deposit_test.go create mode 100644 staticaddr/deposit/manager_height_test.go diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 19c5c2b09..b784b5112 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -52,7 +52,8 @@ type Deposit struct { Value btcutil.Amount // ConfirmationHeight is the absolute height at which the deposit was - // first confirmed. + // first confirmed. A value of zero means the deposit is still + // unconfirmed. ConfirmationHeight int64 // TimeOutSweepPkScript is the pk script that is used to sweep the @@ -91,6 +92,10 @@ func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool { d.Lock() defer d.Unlock() + if d.ConfirmationHeight <= 0 { + return false + } + return currentHeight >= uint32(d.ConfirmationHeight)+expiry } diff --git a/staticaddr/deposit/deposit_test.go b/staticaddr/deposit/deposit_test.go new file mode 100644 index 000000000..3215e116a --- /dev/null +++ b/staticaddr/deposit/deposit_test.go @@ -0,0 +1,17 @@ +package deposit + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDepositIsExpiredUnconfirmed verifies that unconfirmed deposits do not +// expire because their CSV timeout has not started yet. +func TestDepositIsExpiredUnconfirmed(t *testing.T) { + t.Parallel() + + d := &Deposit{} + + require.False(t, d.IsExpired(1_000, 144)) +} diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 7dd8715d0..c5bb85c30 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -42,8 +42,8 @@ var ( // States. var ( - // Deposited signals that funds at a static address have reached the - // confirmation height. + // Deposited signals that funds at a static address have been detected + // and are available to the client. Deposited = fsm.StateType("Deposited") // Withdrawing signals that the withdrawal transaction has been @@ -93,8 +93,8 @@ var ( // Events. var ( // OnStart is sent to the fsm once the deposit outpoint has been - // sufficiently confirmed. It transitions the fsm into the Deposited - // state from where we can trigger a withdrawal, a loopin or an expiry. + // detected. It transitions the fsm into the Deposited state from where + // we can trigger a withdrawal, a loopin or an expiry. OnStart = fsm.EventType("OnStart") // OnWithdrawInitiated is sent to the fsm when a withdrawal has been diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 103b51c52..b8b746394 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -6,6 +6,7 @@ import ( "fmt" "sort" "sync" + "sync/atomic" "time" "github.com/btcsuite/btcd/txscript" @@ -17,9 +18,8 @@ import ( ) const ( - // MinConfs is the minimum number of confirmations we require for a - // deposit to be considered available for loop-ins, coop-spends and - // timeouts. + // MinConfs is the legacy minimum confirmation target deposits had to + // reach before they were considered ready to be used for swaps. MinConfs = 6 // MaxConfs is unset since we don't require a max number of @@ -86,6 +86,9 @@ type Manager struct { // been finalized. The manager will adjust its internal state and flush // finalized deposits from its memory. finalizedDepositChan chan wire.OutPoint + + // currentHeight stores the currently best known block height. + currentHeight atomic.Uint32 } // NewManager creates a new deposit manager. @@ -107,6 +110,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + var startupHeight uint32 + select { + case height := <-newBlockChan: + startupHeight = uint32(height) + m.currentHeight.Store(startupHeight) + + case err = <-newBlockErrChan: + return err + + case <-ctx.Done(): + return ctx.Err() + } + // Recover previous deposits and static address parameters from the DB. err = m.recoverDeposits(ctx) if err != nil { @@ -132,7 +148,15 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { for { select { case height := <-newBlockChan: - err := m.notifyActiveDeposits(ctx, uint32(height)) + m.currentHeight.Store(uint32(height)) + + err := m.reconcileDeposits(ctx) + if err != nil { + log.Errorf("unable to reconcile deposits: %v", err) + continue + } + + err = m.notifyActiveDeposits(ctx, uint32(height)) if err != nil { return err } @@ -226,8 +250,10 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { return nil } -// pollDeposits polls new deposits to our static address and notifies the -// manager's event loop about them. +// pollDeposits periodically polls for new deposits to our static address. This +// complements the block-driven reconciliation in the main event loop: while new +// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker +// here catches deposits that appear in the mempool between blocks. func (m *Manager) pollDeposits(ctx context.Context) { log.Debugf("Waiting for new static address deposits...") @@ -261,12 +287,19 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { log.Tracef("Reconciling new deposits...") utxos, err := m.cfg.AddressManager.ListUnspent( - ctx, MinConfs, MaxConfs, + ctx, 0, MaxConfs, ) if err != nil { return fmt.Errorf("unable to list new deposits: %w", err) } + currentHeight := m.currentHeight.Load() + err = m.updateDepositConfirmations(ctx, utxos, currentHeight) + if err != nil { + return fmt.Errorf("unable to update deposit "+ + "confirmations: %w", err) + } + newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") @@ -274,7 +307,7 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { } for _, utxo := range newDeposits { - deposit, err := m.createNewDeposit(ctx, utxo) + deposit, err := m.createNewDeposit(ctx, utxo, currentHeight) if err != nil { return fmt.Errorf("unable to retain new deposit: %w", err) @@ -294,9 +327,11 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { // createNewDeposit transforms the wallet utxo into a deposit struct and stores // it in our database and manager memory. func (m *Manager) createNewDeposit(ctx context.Context, - utxo *lnwallet.Utxo) (*Deposit, error) { + utxo *lnwallet.Utxo, currentHeight uint32) (*Deposit, error) { - blockHeight, err := m.getBlockHeight(ctx, utxo) + confirmationHeight, err := confirmationHeightForUtxo( + currentHeight, utxo, + ) if err != nil { return nil, err } @@ -324,7 +359,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, state: Deposited, OutPoint: utxo.OutPoint, Value: utxo.Value, - ConfirmationHeight: int64(blockHeight), + ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, } @@ -340,37 +375,72 @@ func (m *Manager) createNewDeposit(ctx context.Context, return deposit, nil } -// getBlockHeight retrieves the block height of a given utxo. -func (m *Manager) getBlockHeight(ctx context.Context, - utxo *lnwallet.Utxo) (uint32, error) { +// confirmationHeightForUtxo derives the first confirmation height of a wallet +// UTXO from the manager's current block height. Unconfirmed UTXOs return 0. +func confirmationHeightForUtxo(currentHeight uint32, + utxo *lnwallet.Utxo) (int64, error) { - addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return 0, fmt.Errorf("couldn't get confirmation height for "+ - "deposit, %w", err) + if utxo.Confirmations <= 0 { + return 0, nil } - notifChan, errChan, err := - m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &utxo.OutPoint.Hash, addressParams.PkScript, - MinConfs, addressParams.InitiationHeight, - ) - if err != nil { - return 0, err + if currentHeight == 0 { + return 0, errors.New("current block height unavailable") } - select { - case tx := <-notifChan: - return tx.BlockHeight, nil + firstConfirmationHeight := int64(currentHeight) - utxo.Confirmations + 1 + if firstConfirmationHeight <= 0 { + return 0, fmt.Errorf("invalid confirmation height %d for %v "+ + "with current height %d and %d confirmations", + firstConfirmationHeight, utxo.OutPoint, currentHeight, + utxo.Confirmations) + } - case err := <-errChan: - return 0, err + return firstConfirmationHeight, nil +} - case <-ctx.Done(): - return 0, ctx.Err() +// updateDepositConfirmations syncs first confirmation heights for deposits that +// are visible in lnd's wallet view. +func (m *Manager) updateDepositConfirmations(ctx context.Context, + utxos []*lnwallet.Utxo, currentHeight uint32) error { + + for _, utxo := range utxos { + m.mu.Lock() + deposit, ok := m.deposits[utxo.OutPoint] + m.mu.Unlock() + if !ok { + continue + } + + deposit.Lock() + previousConfirmationHeight := deposit.ConfirmationHeight + + confirmationHeight, err := confirmationHeightForUtxo( + currentHeight, utxo, + ) + if err != nil { + deposit.Unlock() + return err + } + + if deposit.ConfirmationHeight == confirmationHeight { + deposit.Unlock() + continue + } + + deposit.ConfirmationHeight = confirmationHeight + + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.Unlock() + return err + } + + deposit.Unlock() } + + return nil } // filterNewDeposits filters the given utxos for new deposits that we haven't diff --git a/staticaddr/deposit/manager_height_test.go b/staticaddr/deposit/manager_height_test.go new file mode 100644 index 000000000..100fc502e --- /dev/null +++ b/staticaddr/deposit/manager_height_test.go @@ -0,0 +1,37 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +func TestConfirmationHeightForUtxo(t *testing.T) { + t.Run("unconfirmed", func(t *testing.T) { + height, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{}) + require.NoError(t, err) + require.Zero(t, height) + }) + + t.Run("confirmed", func(t *testing.T) { + height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 2, + }, + Confirmations: 6, + }) + require.NoError(t, err) + require.EqualValues(t, 96, height) + }) + + t.Run("invalid current height", func(t *testing.T) { + _, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{ + Confirmations: 6, + }) + require.ErrorContains(t, err, "invalid confirmation height") + }) +} diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 972feefdc..b71f1d6c5 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -1,14 +1,237 @@ package deposit import ( + "context" + "errors" + "sync" + "sync/atomic" "testing" "time" + "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) +// TestReconcileDepositsSerialized verifies reconciliation is serialized across +// concurrent callers. +func TestReconcileDepositsSerialized(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.TaprootPubkey, + Value: btcutil.Amount(100_000), + Confirmations: 0, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + + mockStore := new(mockStore) + var createCalls atomic.Int32 + createEntered := make(chan struct{}) + releaseCreate := make(chan struct{}) + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(mock.Arguments) { + if createCalls.Add(1) == 1 { + close(createEntered) + } + + <-releaseCreate + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + + var wg sync.WaitGroup + wg.Add(2) + + errs := make(chan error, 2) + go func() { + defer wg.Done() + errs <- manager.reconcileDeposits(ctx) + }() + + <-createEntered + + go func() { + defer wg.Done() + errs <- manager.reconcileDeposits(ctx) + }() + + time.Sleep(100 * time.Millisecond) + close(releaseCreate) + wg.Wait() + close(errs) + + var gotErrs []error + for err := range errs { + gotErrs = append(gotErrs, err) + } + + require.EqualValues(t, 1, createCalls.Load()) + require.Len(t, manager.deposits, 1) + require.Empty(t, manager.activeDeposits) + require.Len(t, gotErrs, 2) + + var errCount int + for _, err := range gotErrs { + if err == nil { + continue + } + + errCount++ + require.ErrorContains(t, err, "unable to start new deposit FSM") + } + require.Equal(t, 1, errCount) +} + +// TestReconcileConfirmedDepositUsesCurrentHeight verifies confirmation heights +// are derived from the manager's current block height. +func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + utxo := &lnwallet.Utxo{ + AddressType: lnwallet.TaprootPubkey, + Value: btcutil.Amount(100_000), + Confirmations: 3, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 1, + }, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + + mockStore := new(mockStore) + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + createdDeposit := args.Get(1).(*Deposit) + require.EqualValues(t, 98, createdDeposit.ConfirmationHeight) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + manager.currentHeight.Store(100) + + err := manager.reconcileDeposits(ctx) + require.ErrorContains(t, err, "unable to start new deposit FSM") +} + +// TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit +// which remains wallet-visible but loses confirmations has its confirmation +// height reset. This can happen if a confirmed transaction is reorged back into +// the mempool. +func TestUpdateDepositConfirmationsResetsReorgedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 2, + } + + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 99, + } + deposit.SetState(Deposited) + + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Confirmations: 0, + } + + mockStore := new(mockStore) + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updatedDeposit := args.Get(1).(*Deposit) + require.Zero(t, updatedDeposit.ConfirmationHeight) + }) + + manager := NewManager(&ManagerConfig{ + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + + err := manager.updateDepositConfirmations(ctx, []*lnwallet.Utxo{utxo}, 0) + require.NoError(t, err) + require.Zero(t, deposit.ConfirmationHeight) + mockStore.AssertExpectations(t) +} + +// TestUpdateDepositConfirmationsRecomputesPositiveHeight verifies that a +// deposit which is confirmed again at a different height after a reorg does +// not retain its stale, positive confirmation height. +func TestUpdateDepositConfirmationsRecomputesPositiveHeight(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 3, + } + + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 100, + } + deposit.SetState(Deposited) + + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Confirmations: 2, + } + + mockStore := new(mockStore) + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updatedDeposit := args.Get(1).(*Deposit) + require.EqualValues(t, 109, updatedDeposit.ConfirmationHeight) + }) + + manager := NewManager(&ManagerConfig{ + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + + err := manager.updateDepositConfirmations( + ctx, []*lnwallet.Utxo{utxo}, 110, + ) + require.NoError(t, err) + require.EqualValues(t, 109, deposit.ConfirmationHeight) + mockStore.AssertExpectations(t) +} + // TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a // duplicated selection is rejected before the manager tries to lock the same // deposit twice. @@ -126,3 +349,87 @@ func TestLockDepositsAllowsReversedConcurrentRequests(t *testing.T) { } } } + +// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement +// UTXO is retained as a new deposit while an in-flight deposit remains tied to +// the outpoint selected by a loop-in. +func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { + ctx := context.Background() + mockLnd := test.NewMockLnd() + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 8, + } + newOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{5}, + Index: 9, + } + + depositID, err := GetRandomDepositID() + require.NoError(t, err) + + deposit := &Deposit{ + ID: depositID, + OutPoint: oldOutpoint, + Value: btcutil.Amount(100_000), + } + deposit.SetState(LoopingIn) + + utxo := &lnwallet.Utxo{ + OutPoint: newOutpoint, + Value: deposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return(&script.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), nil) + + mockStore := new(mockStore) + var createdDeposit *Deposit + mockStore.On( + "CreateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + createdDeposit = args.Get(1).(*Deposit) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + manager.deposits[oldOutpoint] = deposit + fsm := &FSM{} + manager.activeDeposits[oldOutpoint] = fsm + + require.NoError(t, manager.reconcileDeposits(ctx)) + + require.Same(t, deposit, manager.deposits[oldOutpoint]) + require.Equal(t, oldOutpoint, deposit.OutPoint) + require.Equal(t, LoopingIn, deposit.GetState()) + + replacement, ok := manager.deposits[newOutpoint] + require.True(t, ok) + require.Same(t, createdDeposit, replacement) + require.NotEqual(t, depositID, replacement.ID) + require.Equal(t, newOutpoint, replacement.OutPoint) + require.Equal(t, Deposited, replacement.GetState()) + require.Zero(t, replacement.ConfirmationHeight) + + require.Same(t, fsm, manager.activeDeposits[oldOutpoint]) + require.NotSame(t, fsm, manager.activeDeposits[newOutpoint]) + + mockStore.AssertNotCalled( + t, "UpdateDeposit", mock.Anything, mock.Anything, + ) +} diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 1e798ea38..880e286ce 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -133,6 +133,9 @@ func (m *mockAddressManager) ListUnspent(ctx context.Context, minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { args := m.Called(ctx, minConfs, maxConfs) + if listUnspent, ok := args.Get(0).(func() []*lnwallet.Utxo); ok { + return listUnspent(), args.Error(1) + } return args.Get(0).([]*lnwallet.Utxo), args.Error(1) @@ -237,6 +240,10 @@ func TestManager(t *testing.T) { runErrChan <- testContext.manager.Run(ctx, initChan) }() + // Send an initial block so the manager can proceed past its startup + // block wait. + testContext.blockChan <- int32(defaultDepositConfirmations) + // Ensure that the manager has been initialized. select { case <-initChan: @@ -366,6 +373,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { "UpdateDeposit", mock.Anything, mock.Anything, ).Return(nil) + var manager *Manager mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, ).Return(&script.Parameters{ @@ -374,7 +382,19 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, - ).Return([]*lnwallet.Utxo{utxo}, nil) + ).Return(func() []*lnwallet.Utxo { + currentUtxo := *utxo + currentHeight := manager.currentHeight.Load() + if currentHeight < defaultDepositConfirmations { + currentUtxo.Confirmations = 0 + } else { + currentUtxo.Confirmations = int64( + currentHeight - defaultDepositConfirmations + 1, + ) + } + + return []*lnwallet.Utxo{¤tUtxo} + }, nil) // Define the expected return values for the mocks. mockChainNotifier.On( @@ -394,7 +414,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Signer: mockLnd.Signer, } - manager := NewManager(cfg) + manager = NewManager(cfg) testContext := &ManagerTestContext{ manager: manager, From de17e49951b57d64a0e0dae48589fc3ffd589640 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 14:13:21 +0200 Subject: [PATCH 02/35] staticaddr/deposit: replay startup block after recovery The first block epoch is consumed before recovered deposit FSMs exist. Replay that startup height after recovery so already-expired deposits can run expiry handling immediately after restart. --- staticaddr/deposit/manager.go | 8 ++ staticaddr/deposit/manager_test.go | 166 +++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index b8b746394..2abe495ef 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -136,6 +136,14 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { err = m.reconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) + } else { + // The startup height was consumed before recovered deposit FSMs + // existed. Replay it so already-expired recovered deposits can act + // immediately, but only after their wallet view is fresh. + err = m.notifyActiveDeposits(ctx, startupHeight) + if err != nil { + return err + } } // Start the deposit notifier. diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 880e286ce..8904a515f 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -3,6 +3,7 @@ package deposit import ( "context" "encoding/hex" + "errors" "testing" "time" @@ -141,6 +142,21 @@ func (m *mockAddressManager) ListUnspent(ctx context.Context, args.Error(1) } +// listUnspentOverride delegates all address-manager methods except +// ListUnspent to another implementation. +type listUnspentOverride struct { + AddressManager + + listUnspent func(context.Context, int32, int32) ([]*lnwallet.Utxo, + error) +} + +func (l *listUnspentOverride) ListUnspent(ctx context.Context, + minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { + + return l.listUnspent(ctx, minConfs, maxConfs) +} + func (m *mockAddressManager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot, error) { @@ -314,6 +330,156 @@ func TestManager(t *testing.T) { } } +// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial +// block epoch consumed during startup is delivered to recovered deposit FSMs. +func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + const defaultTimeout = 30 * time.Second + + testContext := newManagerTestContext(t) + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + // Send only the startup block at the recovered deposit's expiry height. + testContext.blockChan <- int32( + defaultDepositConfirmations + defaultExpiry, + ) + + select { + case <-initChan: + + case err := <-runErrChan: + require.NoError(t, err, "manager failed to start") + + case <-time.After(defaultTimeout): + t.Fatal("manager timed out starting") + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive sign request") + } + + select { + case <-testContext.mockLnd.TxPublishChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive published expiry tx") + } + + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(defaultTimeout): + t.Fatal("manager did not stop") + } +} + +// TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit +// FSMs cannot make an expiry decision from stale confirmation data when wallet +// reconciliation fails at startup or while processing a later block. +func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { + testCases := []struct { + name string + startupHeight int32 + blockHeight int32 + }{ + { + name: "startup", + startupHeight: int32( + defaultDepositConfirmations + defaultExpiry, + ), + }, + { + name: "block", + startupHeight: int32(defaultDepositConfirmations), + blockHeight: int32( + defaultDepositConfirmations + defaultExpiry, + ), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + testContext := newManagerTestContext(t) + baseAddressManager := testContext.mockAddressManager + var listUnspentCalls int + testContext.manager.cfg.AddressManager = + &listUnspentOverride{ + AddressManager: baseAddressManager, + listUnspent: func(ctx context.Context, + minConfs, maxConfs int32) ( + []*lnwallet.Utxo, error) { + + listUnspentCalls++ + if testCase.blockHeight != 0 && + listUnspentCalls == 1 { + + return baseAddressManager.ListUnspent( + ctx, minConfs, maxConfs, + ) + } + + return nil, errors.New( + "injected reconciliation failure", + ) + }, + } + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + testContext.blockChan <- testCase.startupHeight + + select { + case <-initChan: + + case err := <-runErrChan: + require.NoError(t, err, "manager failed to start") + + case <-time.After(time.Second): + t.Fatal("manager timed out starting") + } + + if testCase.blockHeight != 0 { + testContext.blockChan <- testCase.blockHeight + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + t.Fatal("expiry sweep signed with stale deposit data") + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(time.Second): + t.Fatal("manager did not stop") + } + }) + } +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { From bce5897631fab0eba87414ab80566450a163486d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:51:12 +0200 Subject: [PATCH 03/35] staticaddr/deposit: reconcile active deposits with wallet Treat lnd's wallet view as the source of spendable static-address outpoints while keeping historical deposit records in the DB. Reconcile active FSMs against the current wallet view, reactivate known deposits that reappear, and hide stale Deposited records from the visible deposit set. --- staticaddr/deposit/manager.go | 132 +++++++- staticaddr/deposit/manager_reconcile_test.go | 304 ++++++++++++++++++- 2 files changed, 433 insertions(+), 3 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 2abe495ef..30f4532fb 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -284,6 +284,15 @@ func (m *Manager) pollDeposits(ctx context.Context) { }() } +// EnsureDepositsFresh reconciles the cached active deposit set with lnd's +// current wallet view. Spending paths call this before selecting deposits so +// stale persisted records are not treated as live funds. This can happen when +// an unconfirmed funding transaction is replaced, a confirmed deposit is +// reorged out, or the output was spent outside the active manager path. +func (m *Manager) EnsureDepositsFresh(ctx context.Context) error { + return m.reconcileDeposits(ctx) +} + // reconcileDeposits fetches all spends to our static addresses from our lnd // wallet and matches it against the deposits in our memory that we've seen so // far. It picks the newly identified deposits and starts a state machine per @@ -308,6 +317,11 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { "confirmations: %w", err) } + err = m.syncActiveDeposits(ctx, utxos) + if err != nil { + return fmt.Errorf("unable to sync active deposits: %w", err) + } + newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") @@ -451,6 +465,92 @@ func (m *Manager) updateDepositConfirmations(ctx context.Context, return nil } +// syncActiveDeposits reconciles the live active set with lnd's current wallet +// view. Known Deposited records that are visible but inactive become active +// again, and active Deposited records that are no longer wallet-visible are +// removed from the live set. The DB record is left untouched as historical +// evidence that the outpoint was once detected. +func (m *Manager) syncActiveDeposits(ctx context.Context, + utxos []*lnwallet.Utxo) error { + + currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + currentUtxos[utxo.OutPoint] = struct{}{} + } + + type deactivatedDeposit struct { + outpoint wire.OutPoint + fsm *FSM + } + + m.mu.Lock() + toActivate := make([]*Deposit, 0, len(utxos)) + for _, utxo := range utxos { + deposit, ok := m.deposits[utxo.OutPoint] + if !ok { + continue + } + + if _, active := m.activeDeposits[utxo.OutPoint]; active { + continue + } + + if !deposit.IsInState(Deposited) { + continue + } + + toActivate = append(toActivate, deposit) + } + + toDeactivate := make( + []deactivatedDeposit, 0, len(m.activeDeposits), + ) + for outpoint, fsm := range m.activeDeposits { + if _, ok := currentUtxos[outpoint]; ok { + continue + } + + if fsm == nil || fsm.deposit == nil { + continue + } + + if !fsm.deposit.IsInState(Deposited) { + continue + } + + delete(m.activeDeposits, outpoint) + toDeactivate = append(toDeactivate, deactivatedDeposit{ + outpoint: outpoint, + fsm: fsm, + }) + } + m.mu.Unlock() + + for _, deactivated := range toDeactivate { + deactivated.fsm.Stop() + + log.Infof("Removed vanished deposit %v from active set", + deactivated.outpoint) + } + + for _, deposit := range toActivate { + if !deposit.IsInState(Deposited) { + continue + } + + err := m.startDepositFsm(ctx, deposit) + if err != nil { + m.removeActiveDeposit(deposit.OutPoint) + + return err + } + + log.Infof("Reactivated visible deposit %v", deposit.OutPoint) + } + + return nil +} + // filterNewDeposits filters the given utxos for new deposits that we haven't // seen before. func (m *Manager) filterNewDeposits(utxos []*lnwallet.Utxo) []*lnwallet.Utxo { @@ -682,11 +782,41 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) { } } -// GetAllDeposits returns all active deposits. +// GetAllDeposits returns all known deposits from the database. func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) { return m.cfg.Store.AllDeposits(ctx) } +// GetVisibleDeposits returns deposits that should be exposed through normal +// user-facing views. The database can contain historical Deposited rows whose +// outpoints are no longer present in lnd's current wallet view, for example +// after replacement or reorg. Once the manager has recovered its live cache, +// plain Deposited records are only visible while their outpoint is in the +// active set. +func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) { + deposits, err := m.cfg.Store.AllDeposits(ctx) + if err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + liveCacheReady := len(m.deposits) > 0 + filtered := make([]*Deposit, 0, len(deposits)) + for _, d := range deposits { + if liveCacheReady && d.IsInState(Deposited) { + if _, ok := m.activeDeposits[d.OutPoint]; !ok { + continue + } + } + + filtered = append(filtered, d) + } + + return filtered, nil +} + // UpdateDeposit overrides all fields of the deposit with given ID in the store. func (m *Manager) UpdateDeposit(ctx context.Context, d *Deposit) error { d.Lock() diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index b71f1d6c5..dff337d63 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -3,6 +3,7 @@ package deposit import ( "context" "errors" + "strings" "sync" "sync/atomic" "testing" @@ -11,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/test" @@ -101,9 +103,18 @@ func TestReconcileDepositsSerialized(t *testing.T) { } errCount++ - require.ErrorContains(t, err, "unable to start new deposit FSM") + errMsg := err.Error() + require.True( + t, + strings.Contains( + errMsg, "unable to start new deposit FSM", + ) || strings.Contains( + errMsg, "unable to sync active deposits", + ), + "unexpected error: %v", err, + ) } - require.Equal(t, 1, errCount) + require.Equal(t, 2, errCount) } // TestReconcileConfirmedDepositUsesCurrentHeight verifies confirmation heights @@ -232,6 +243,102 @@ func TestUpdateDepositConfirmationsRecomputesPositiveHeight(t *testing.T) { mockStore.AssertExpectations(t) } +// TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit verifies that a +// missing wallet outpoint is removed from the live active set without mutating +// its historical DB state. +func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 7, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{}, nil) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: new(mockStore), + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + deposit: deposit, + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-fsm.stopChan + close(fsm.quitChan) + }() + manager.activeDeposits[outpoint] = fsm + + require.NoError(t, manager.reconcileDeposits(ctx)) + require.Equal(t, Deposited, deposit.GetState()) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after deposit vanished") + } +} + +// TestReconcileDepositsDeactivatesVanishedConfirmedDeposit verifies that a +// previously confirmed deposit is also removed from the live active set if it +// vanishes from the wallet view. +func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 4, + } + + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 123, + } + deposit.SetState(Deposited) + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{}, nil) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: new(mockStore), + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + deposit: deposit, + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-fsm.stopChan + close(fsm.quitChan) + }() + manager.activeDeposits[outpoint] = fsm + + require.NoError(t, manager.reconcileDeposits(ctx)) + require.Equal(t, Deposited, deposit.GetState()) + require.EqualValues(t, 123, deposit.ConfirmationHeight) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after confirmed deposit vanished") + } +} + // TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a // duplicated selection is rejected before the manager tries to lock the same // deposit twice. @@ -350,6 +457,199 @@ func TestLockDepositsAllowsReversedConcurrentRequests(t *testing.T) { } } +// TestReconcileDepositsReactivatesReappearedDeposit verifies that the same +// outpoint can become active again if lnd reports it after a prior wallet-view +// miss. +func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 5, + } + + deposit := &Deposit{ + OutPoint: outpoint, + Value: btcutil.Amount(100_000), + ConfirmationHeight: 77, + } + deposit.SetState(Deposited) + + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Value: deposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return(&script.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, nil) + mockAddressManager.On( + "GetStaticAddress", mock.Anything, + ).Return((*script.StaticAddress)(nil), nil) + + mockStore := new(mockStore) + var updateStates []fsm.StateType + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updatedDeposit := args.Get(1).(*Deposit) + updateStates = append(updateStates, updatedDeposit.state) + if updatedDeposit.isInStateNoLock(Deposited) { + require.Zero(t, updatedDeposit.ConfirmationHeight) + } + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + + // Reconciliation should reactivate the existing record instead of + // creating a second deposit entry for the same outpoint. + require.NoError(t, manager.reconcileDeposits(ctx)) + require.Equal(t, Deposited, deposit.GetState()) + require.Zero(t, deposit.ConfirmationHeight) + require.Len(t, manager.activeDeposits, 1) + require.Equal(t, []fsm.StateType{Deposited}, updateStates) +} + +// TestReconcileDepositsKeepsInactiveOnFSMStartFailure verifies that a failed +// reactivation does not leave memory saying a deposit is active without an FSM. +func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{11}, + Index: 5, + } + + deposit := &Deposit{ + OutPoint: outpoint, + Value: btcutil.Amount(100_000), + ConfirmationHeight: 77, + } + deposit.SetState(Deposited) + + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Value: deposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + + var ( + updateStates []fsm.StateType + updateHeights []int64 + ) + mockStore := new(mockStore) + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updatedDeposit := args.Get(1).(*Deposit) + updateStates = append(updateStates, updatedDeposit.state) + updateHeights = append( + updateHeights, updatedDeposit.ConfirmationHeight, + ) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + + err := manager.reconcileDeposits(ctx) + require.ErrorContains(t, err, "unable to sync active deposits") + require.Equal(t, Deposited, deposit.GetState()) + require.Zero(t, deposit.ConfirmationHeight) + require.Empty(t, manager.activeDeposits) + require.Equal(t, []fsm.StateType{Deposited}, updateStates) + require.EqualValues(t, []int64{0}, updateHeights) +} + +// TestReconcileDepositsDeactivatesBeforeActivationFailure verifies that a +// failed reactivation of one visible deposit does not leave another vanished +// deposit in the live active set. +func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { + ctx := context.Background() + visibleOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{21}, + Index: 5, + } + vanishedOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{22}, + Index: 6, + } + + visibleDeposit := &Deposit{ + OutPoint: visibleOutpoint, + Value: btcutil.Amount(100_000), + } + visibleDeposit.SetState(Deposited) + + vanishedDeposit := &Deposit{ + OutPoint: vanishedOutpoint, + Value: btcutil.Amount(100_000), + } + vanishedDeposit.SetState(Deposited) + + utxo := &lnwallet.Utxo{ + OutPoint: visibleOutpoint, + Value: visibleDeposit.Value, + Confirmations: 0, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + mockAddressManager.On( + "GetStaticAddressParameters", mock.Anything, + ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: new(mockStore), + }) + manager.deposits[visibleOutpoint] = visibleDeposit + manager.deposits[vanishedOutpoint] = vanishedDeposit + + vanishedFsm := &FSM{ + deposit: vanishedDeposit, + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-vanishedFsm.stopChan + close(vanishedFsm.quitChan) + }() + manager.activeDeposits[vanishedOutpoint] = vanishedFsm + + err := manager.reconcileDeposits(ctx) + require.ErrorContains(t, err, "unable to sync active deposits") + require.Empty(t, manager.activeDeposits) + + select { + case <-vanishedFsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("vanished deposit fsm did not stop") + } +} + // TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement // UTXO is retained as a new deposit while an in-flight deposit remains tied to // the outpoint selected by a loop-in. From 8f7e1ed01c8677203ad22ed4eb03fc52bb6cb63a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:51:46 +0200 Subject: [PATCH 04/35] staticaddr: expose tracked deposit availability Build list and summary responses from tracked deposit records instead of raw wallet UTXOs so RPC clients see the manager availability state. Split unconfirmed value from confirmed deposited value in summaries, and reject manual loop-in quotes for selected deposits that are not currently Deposited. --- loopd/swapclient_server.go | 124 ++++++++------ loopd/swapclient_server_deposit_test.go | 23 +++ loopd/swapclient_server_staticaddr_test.go | 135 ++++++++++++++++ loopd/swapclient_server_test.go | 179 ++++++++++++++++----- 4 files changed, 366 insertions(+), 95 deletions(-) create mode 100644 loopd/swapclient_server_deposit_test.go create mode 100644 loopd/swapclient_server_staticaddr_test.go diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 7bf5ab533..33514b972 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -96,7 +96,7 @@ type swapClientServer struct { reservationManager *reservation.Manager instantOutManager *instantout.Manager staticAddressManager *address.Manager - depositManager *deposit.Manager + depositManager staticAddressDepositManager withdrawalManager *withdraw.Manager staticLoopInManager *loopin.Manager openChannelManager *openchannel.Manager @@ -112,6 +112,31 @@ type swapClientServer struct { stopDaemon func() } +// staticAddressDepositManager is the deposit manager behavior required by the +// RPC server. +type staticAddressDepositManager interface { + // EnsureDepositsFresh reconciles tracked deposits with lnd's current + // wallet view before user-facing deposit selection. + EnsureDepositsFresh(context.Context) error + + // GetActiveDepositsInState returns active deposits that are currently in + // the requested state. + GetActiveDepositsInState(fsm.StateType) ([]*deposit.Deposit, error) + + // DepositsForOutpoints returns known deposit records for the requested + // outpoints, optionally skipping unknown outpoints. + DepositsForOutpoints(context.Context, []string, bool) ( + []*deposit.Deposit, error) + + // GetVisibleDeposits returns deposits that should be shown in normal + // user-facing views. + GetVisibleDeposits(context.Context) ([]*deposit.Deposit, error) + + // GetAllDeposits returns all known deposit records, including historical + // records that are no longer user-visible. + GetAllDeposits(context.Context) ([]*deposit.Deposit, error) +} + // LoopOut initiates a loop out swap with the given parameters. The call returns // after the swap has been set up with the swap server. From that point onwards, // progress can be tracked via the LoopOutStatus stream that is returned from @@ -977,15 +1002,22 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, return nil, fmt.Errorf("expected %d deposits, got %d", len(req.DepositOutpoints), len(depositList.FilteredDeposits)) - } else { - numDeposits = len(depositList.FilteredDeposits) } + numDeposits = len(depositList.FilteredDeposits) // In case we quote for deposits, we send the server both the // selected value and the number of deposits. This is so the // server can probe the selected value and calculate the per // input fee. for _, deposit := range depositList.FilteredDeposits { + // For a manual quote we require the current state to be + // Deposited so a stale client-side outpoint selection + // fails early instead of making it to swap initiation. + if deposit.State != looprpc.DepositState_DEPOSITED { + return nil, fmt.Errorf("deposit %s is not "+ + "currently available", deposit.Outpoint) + } + totalDepositAmount += btcutil.Amount( deposit.Value, ) @@ -1693,58 +1725,40 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, } // ListUnspentRaw returns the unspent wallet view of the backing lnd - // wallet. It might be that deposits show up there that are actually - // not spendable because they already have been used but not yet spent - // by the server. We filter out such deposits here. + // wallet. Static loop-in initiation requires an active deposit record, + // so only deposits that are both wallet-visible and tracked as + // Deposited are returned here. var ( outpoints []string isUnspent = make(map[wire.OutPoint]struct{}) ) - // Keep track of confirmed outpoints that we need to check against our - // database. - confirmedToCheck := make(map[wire.OutPoint]struct{}) - for _, utxo := range utxos { - if utxo.Confirmations < deposit.MinConfs { - // Unconfirmed deposits are always available. - isUnspent[utxo.OutPoint] = struct{}{} - } else { - // Confirmed deposits need to be checked. - outpoints = append(outpoints, utxo.OutPoint.String()) - confirmedToCheck[utxo.OutPoint] = struct{}{} - } + outpoints = append(outpoints, utxo.OutPoint.String()) + } + + err = s.depositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, err } // Check the spent status of the deposits by looking at their states. - ignoreUnknownOutpoints := false + ignoreUnknownOutpoints := true deposits, err := s.depositManager.DepositsForOutpoints( ctx, outpoints, ignoreUnknownOutpoints, ) if err != nil { return nil, err } + for _, d := range deposits { - // A nil deposit means we don't have a record for it. We'll - // handle this case after the loop. if d == nil { continue } - // If the deposit is in the "Deposited" state, it's available. if d.IsInState(deposit.Deposited) { isUnspent[d.OutPoint] = struct{}{} } - - // We have a record for this deposit, so we no longer need to - // check it. - delete(confirmedToCheck, d.OutPoint) - } - - // Any remaining outpoints in confirmedToCheck are ones that lnd knows - // about but we don't. These are new, unspent deposits. - for op := range confirmedToCheck { - isUnspent[op] = struct{}{} } // Prepare the list of unspent deposits for the rpc response. @@ -1988,9 +2002,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, for _, d := range ds { state := toClientDepositState(d.GetState()) confirmationHeight := d.GetConfirmationHeight() - blocksUntilExpiry := confirmationHeight + - int64(addrParams.Expiry) - - int64(lndInfo.BlockHeight) + blocksUntilExpiry := depositBlocksUntilExpiry( + confirmationHeight, addrParams.Expiry, + int64(lndInfo.BlockHeight), + ) pd := &looprpc.Deposit{ Id: d.ID[:], @@ -2082,23 +2097,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, htlcTimeoutSwept int64 ) - // Value unconfirmed. - utxos, err := s.staticAddressManager.ListUnspent( - ctx, 0, deposit.MinConfs-1, - ) - if err != nil { - return nil, err - } - for _, u := range utxos { - valueUnconfirmed += int64(u.Value) - } - - // Confirmed total values by category. + // Total values by category. for _, d := range allDeposits { value := int64(d.Value) switch d.GetState() { case deposit.Deposited: - valueDeposited += value + if d.GetConfirmationHeight() <= 0 { + valueUnconfirmed += value + } else { + valueDeposited += value + } case deposit.Expired: valueExpired += value @@ -2248,13 +2256,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, return err } for i := range len(deposits) { - deposits[i].BlocksUntilExpiry = - deposits[i].ConfirmationHeight + - int64(params.Expiry) - bestBlockHeight + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( + deposits[i].ConfirmationHeight, params.Expiry, + bestBlockHeight, + ) } return nil } +// depositBlocksUntilExpiry returns the remaining blocks until a deposit +// expires. Unconfirmed deposits return the full CSV value because the timeout +// has not started yet. +func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32, + bestBlockHeight int64) int64 { + + if confirmationHeight <= 0 { + return int64(expiry) + } + + return confirmationHeight + int64(expiry) - bestBlockHeight +} + // StaticOpenChannel initiates an open channel request using static address // deposits. func (s *swapClientServer) StaticOpenChannel(ctx context.Context, diff --git a/loopd/swapclient_server_deposit_test.go b/loopd/swapclient_server_deposit_test.go new file mode 100644 index 000000000..9bfe5b49c --- /dev/null +++ b/loopd/swapclient_server_deposit_test.go @@ -0,0 +1,23 @@ +package loopd + +import ( + "testing" +) + +// TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for +// confirmed and unconfirmed deposits. +func TestDepositBlocksUntilExpiry(t *testing.T) { + t.Run("unconfirmed", func(t *testing.T) { + if blocks := depositBlocksUntilExpiry(0, 144, 500); blocks != 144 { + t.Fatalf("expected 144 blocks for unconfirmed deposit, got %d", + blocks) + } + }) + + t.Run("confirmed", func(t *testing.T) { + if blocks := depositBlocksUntilExpiry(450, 144, 500); blocks != 94 { + t.Fatalf("expected 94 blocks until expiry, got %d", + blocks) + } + }) +} diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go new file mode 100644 index 000000000..3d229a608 --- /dev/null +++ b/loopd/swapclient_server_staticaddr_test.go @@ -0,0 +1,135 @@ +package loopd + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + mock_lnd "github.com/lightninglabs/loop/test" + "github.com/stretchr/testify/require" +) + +type staticAddrDepositStore struct { + allDeposits []*deposit.Deposit + byOutpoint map[string]*deposit.Deposit +} + +// CreateDeposit implements deposit.Store for static address server tests. +func (s *staticAddrDepositStore) CreateDeposit(context.Context, + *deposit.Deposit) error { + + return nil +} + +// UpdateDeposit implements deposit.Store for static address server tests. +func (s *staticAddrDepositStore) UpdateDeposit(context.Context, + *deposit.Deposit) error { + + return nil +} + +// GetDeposit implements deposit.Store for static address server tests. +func (s *staticAddrDepositStore) GetDeposit(context.Context, + deposit.ID) (*deposit.Deposit, error) { + + return nil, nil +} + +// DepositForOutpoint returns the deposit for the requested outpoint. +func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context, + outpoint string) (*deposit.Deposit, error) { + + if deposit, ok := s.byOutpoint[outpoint]; ok { + return deposit, nil + } + + return nil, deposit.ErrDepositNotFound +} + +// AllDeposits returns all deposits seeded into the test store. +func (s *staticAddrDepositStore) AllDeposits(context.Context) ( + []*deposit.Deposit, error) { + + return s.allDeposits, nil +} + +// newTestDepositManager creates a deposit manager backed by seeded deposits. +func newTestDepositManager( + deposits ...*deposit.Deposit) *deposit.Manager { + + byOutpoint := make(map[string]*deposit.Deposit, len(deposits)) + for _, deposit := range deposits { + byOutpoint[deposit.OutPoint.String()] = deposit + } + + return deposit.NewManager(&deposit.ManagerConfig{ + Store: &staticAddrDepositStore{ + allDeposits: deposits, + byOutpoint: byOutpoint, + }, + }) +} + +// newTestStaticAddressContext creates static address test dependencies. +func newTestStaticAddressContext(t *testing.T) (*address.Manager, + *mock_lnd.LndMockServices) { + + t.Helper() + + mock := mock_lnd.NewMockLnd() + _, client := mock_lnd.CreateKey(1) + _, server := mock_lnd.CreateKey(2) + + addrStore := &mockAddressStore{ + params: []*script.Parameters{{ + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: []byte("pkscript"), + }}, + } + + addrMgr, err := address.NewManager(&address.ManagerConfig{ + Store: addrStore, + WalletKit: mock.WalletKit, + ChainParams: mock.ChainParams, + }, 1) + require.NoError(t, err) + + return addrMgr, mock +} + +// TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote +// requests fail for selected deposits that are no longer available. +func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { + t.Parallel() + setLogger(btclog.Disabled) + + locked := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{6}, + Index: 6, + }, + Value: btcutil.Amount(5_000), + } + locked.SetState(deposit.LoopingIn) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(locked), + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + _, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{ + DepositOutpoints: []string{locked.OutPoint.String()}, + }) + require.ErrorContains(t, err, "is not currently available") +} diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 0857bbf2e..58c5dac97 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -2,6 +2,7 @@ package loopd import ( "context" + "fmt" "os" "testing" "time" @@ -1321,7 +1322,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context, if d, ok := s.byOutpoint[outpoint]; ok { return d, nil } - return nil, nil + return nil, deposit.ErrDepositNotFound } func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit, @@ -1335,6 +1336,90 @@ func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit, return deposits, nil } +// listUnspentDepositManager backs ListUnspentDeposits tests without requiring +// the full deposit manager event loop. +type listUnspentDepositManager struct { + byOutpoint map[string]*deposit.Deposit + + ensureDepositsFreshCalls int + onEnsureDepositsFresh func(*listUnspentDepositManager) +} + +func (m *listUnspentDepositManager) EnsureDepositsFresh( + context.Context) error { + + m.ensureDepositsFreshCalls++ + if m.onEnsureDepositsFresh != nil { + m.onEnsureDepositsFresh(m) + } + + return nil +} + +func (m *listUnspentDepositManager) GetActiveDepositsInState( + state fsm.StateType) ([]*deposit.Deposit, error) { + + deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint)) + for _, d := range m.byOutpoint { + if !d.IsInState(state) { + continue + } + + deposits = append(deposits, d) + } + + return deposits, nil +} + +func (m *listUnspentDepositManager) DepositsForOutpoints( + _ context.Context, outpoints []string, ignoreUnknown bool) ( + []*deposit.Deposit, error) { + + deposits := make([]*deposit.Deposit, 0, len(outpoints)) + seen := make(map[string]struct{}, len(outpoints)) + for i, outpoint := range outpoints { + if _, ok := seen[outpoint]; ok { + return nil, fmt.Errorf("duplicate outpoint %s "+ + "at index %d", outpoint, i) + } + seen[outpoint] = struct{}{} + + d, ok := m.byOutpoint[outpoint] + if !ok { + if ignoreUnknown { + continue + } + + return nil, deposit.ErrDepositNotFound + } + + deposits = append(deposits, d) + } + + return deposits, nil +} + +func (m *listUnspentDepositManager) GetVisibleDeposits( + context.Context) ([]*deposit.Deposit, error) { + + return m.allDeposits(), nil +} + +func (m *listUnspentDepositManager) GetAllDeposits( + context.Context) ([]*deposit.Deposit, error) { + + return m.allDeposits(), nil +} + +func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit { + deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint)) + for _, d := range m.byOutpoint { + deposits = append(deposits, d) + } + + return deposits +} + // TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits. func TestListUnspentDeposits(t *testing.T) { ctx := context.Background() @@ -1376,39 +1461,41 @@ func TestListUnspentDeposits(t *testing.T) { } } - minConfs := int64(deposit.MinConfs) - utxoBelow := makeUtxo(0, minConfs-1) // always included - utxoAt := makeUtxo(1, minConfs) // included only if Deposited - utxoAbove1 := makeUtxo(2, minConfs+1) - utxoAbove2 := makeUtxo(3, minConfs+2) + utxoUnknown := makeUtxo(0, 0) + utxoDeposited := makeUtxo(1, 1) + utxoWithdrawn := makeUtxo(2, 2) + utxoLoopingIn := makeUtxo(3, 5) + utxoConfirmedUnknown := makeUtxo(4, 3) // Helper to build the deposit manager with specific states. buildDepositMgr := func( - states map[wire.OutPoint]fsm.StateType) *deposit.Manager { + states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager { - store := &mockDepositStore{ + depMgr := &listUnspentDepositManager{ byOutpoint: make(map[string]*deposit.Deposit), } for op, state := range states { d := &deposit.Deposit{OutPoint: op} d.SetState(state) - store.byOutpoint[op.String()] = d + depMgr.byOutpoint[op.String()] = d } - return deposit.NewManager(&deposit.ManagerConfig{Store: store}) + return depMgr } - // Include below-min-conf and >=min with Deposited; exclude others. - t.Run("below min conf always, Deposited included, others excluded", + // Only known Deposited records are available. Unknown deposits and + // known non-Deposited states are excluded. + t.Run("only known Deposited included", func(t *testing.T) { mock.SetListUnspent([]*lnwallet.Utxo{ - utxoBelow, utxoAt, utxoAbove1, utxoAbove2, + utxoUnknown, utxoDeposited, utxoWithdrawn, + utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ - utxoAt.OutPoint: deposit.Deposited, - utxoAbove1.OutPoint: deposit.Withdrawn, - utxoAbove2.OutPoint: deposit.LoopingIn, + utxoDeposited.OutPoint: deposit.Deposited, + utxoWithdrawn.OutPoint: deposit.Withdrawn, + utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ @@ -1420,9 +1507,10 @@ func TestListUnspentDeposits(t *testing.T) { ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) + require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) - // Expect utxoBelow and utxoAt only. - require.Len(t, resp.Utxos, 2) + // Expect the Deposited utxo only. + require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} @@ -1430,25 +1518,23 @@ func TestListUnspentDeposits(t *testing.T) { // same across utxos. require.NotEmpty(t, u.StaticAddress) } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAt.OutPoint.String()] - require.True(t, ok1) - require.True(t, ok2) + _, ok := got[utxoDeposited.OutPoint.String()] + require.True(t, ok) }) - // Swap states, now include utxoBelow and utxoAbove1. - t.Run("Deposited on >=min included; non-Deposited excluded", + // Confirmation depth no longer changes availability; state does. + t.Run("availability ignores conf depth once deposit state is known", func(t *testing.T) { mock.SetListUnspent( []*lnwallet.Utxo{ - utxoBelow, utxoAt, utxoAbove1, - utxoAbove2, + utxoUnknown, utxoDeposited, + utxoWithdrawn, utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ - utxoAt.OutPoint: deposit.Withdrawn, - utxoAbove1.OutPoint: deposit.Deposited, - utxoAbove2.OutPoint: deposit.Withdrawn, + utxoDeposited.OutPoint: deposit.Deposited, + utxoWithdrawn.OutPoint: deposit.Withdrawn, + utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ @@ -1460,26 +1546,32 @@ func TestListUnspentDeposits(t *testing.T) { ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) + require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) - require.Len(t, resp.Utxos, 2) + require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAbove1.OutPoint.String()] - require.True(t, ok1) - require.True(t, ok2) + _, ok := got[utxoDeposited.OutPoint.String()] + require.True(t, ok) }) - // Confirmed UTXO not present in store should be included. - t.Run("confirmed utxo not in store is included", func(t *testing.T) { - // Only return a confirmed UTXO from lnd and make sure the - // deposit manager/store doesn't know about it. - mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2}) + // A wallet-visible UTXO reconciled by EnsureDepositsFresh should be + // returned in the same ListUnspentDeposits call. + t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) { + mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown}) - // Empty store (no states for any outpoint). depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) + depMgr.onEnsureDepositsFresh = func( + m *listUnspentDepositManager) { + + d := &deposit.Deposit{ + OutPoint: utxoConfirmedUnknown.OutPoint, + } + d.SetState(deposit.Deposited) + m.byOutpoint[d.OutPoint.String()] = d + } server := &swapClientServer{ staticAddressManager: addrMgr, @@ -1490,13 +1582,12 @@ func TestListUnspentDeposits(t *testing.T) { ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) + require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) - // We expect the confirmed UTXO to be included even though it - // doesn't exist in the store yet. require.Len(t, resp.Utxos, 1) require.Equal( - t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint, + t, utxoConfirmedUnknown.OutPoint.String(), + resp.Utxos[0].Outpoint, ) - require.NotEmpty(t, resp.Utxos[0].StaticAddress) }) } From b29e12991bd81a39f4aeb24bd74bcb09fbc48e8b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:53:07 +0200 Subject: [PATCH 05/35] staticaddr: require confirmed deposits for withdraws and channel opens Deposited can now include mempool outputs for static loop-ins, but withdrawals and static channel opens still require confirmed funding inputs. Filter automatic channel-open selection to confirmed deposits and reject explicit unconfirmed selections, including withdraw-all requests that would otherwise silently include mempool deposits. --- loopd/swapclient_server.go | 24 +++++- loopd/swapclient_server_deposit_test.go | 63 ++++++++++++++ staticaddr/openchannel/manager.go | 28 ++++++ staticaddr/openchannel/manager_test.go | 108 ++++++++++++++++++++++-- staticaddr/withdraw/manager.go | 9 ++ 5 files changed, 224 insertions(+), 8 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 33514b972..44a0af0fb 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1805,8 +1805,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context, return nil, err } - for _, d := range deposits { - outpoints = append(outpoints, d.OutPoint) + outpoints, err = withdrawAllDepositOutpoints(deposits) + if err != nil { + return nil, err } case isUtxoSelected: @@ -1829,6 +1830,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context, }, err } +// withdrawAllDepositOutpoints returns all deposit outpoints for an `all` +// withdrawal request. The request must fail if any deposited output is still +// unconfirmed because `all` should not silently downgrade to a subset. +func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint, + error) { + + outpoints := make([]wire.OutPoint, 0, len(deposits)) + for _, d := range deposits { + if d.GetConfirmationHeight() <= 0 { + return nil, fmt.Errorf("can't withdraw all deposits while " + + "some deposits are unconfirmed") + } + + outpoints = append(outpoints, d.OutPoint) + } + + return outpoints, nil +} + // ListStaticAddressDeposits returns a list of all sufficiently confirmed // deposits behind the static address and displays properties like value, // state or blocks til expiry. diff --git a/loopd/swapclient_server_deposit_test.go b/loopd/swapclient_server_deposit_test.go index 9bfe5b49c..3d4ce065a 100644 --- a/loopd/swapclient_server_deposit_test.go +++ b/loopd/swapclient_server_deposit_test.go @@ -2,6 +2,10 @@ package loopd import ( "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/staticaddr/deposit" ) // TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for @@ -21,3 +25,62 @@ func TestDepositBlocksUntilExpiry(t *testing.T) { } }) } + +// TestWithdrawAllDepositOutpoints checks `all` withdrawal handling for +// confirmed and unconfirmed deposits. +func TestWithdrawAllDepositOutpoints(t *testing.T) { + t.Run("rejects unconfirmed", func(t *testing.T) { + deposits := []*deposit.Deposit{ + { + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + }, + { + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + ConfirmationHeight: 123, + }, + } + + _, err := withdrawAllDepositOutpoints(deposits) + if err == nil { + t.Fatal("expected unconfirmed deposit to fail all withdrawal") + } + }) + + t.Run("returns all confirmed", func(t *testing.T) { + first := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + } + second := wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 4, + } + deposits := []*deposit.Deposit{ + { + OutPoint: first, + ConfirmationHeight: 123, + }, + { + OutPoint: second, + ConfirmationHeight: 124, + }, + } + + outpoints, err := withdrawAllDepositOutpoints(deposits) + if err != nil { + t.Fatalf("expected confirmed deposits to succeed: %v", err) + } + if len(outpoints) != 2 { + t.Fatalf("expected 2 outpoints, got %d", len(outpoints)) + } + if outpoints[0] != first || outpoints[1] != second { + t.Fatal("expected all confirmed outpoints to remain selected") + } + }) +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index d1fda2f3a..5128c168a 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -305,6 +305,10 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } + // Automatic channel funding must ignore mempool deposits because + // they cannot yet be used as funding inputs. + deposits = filterConfirmedDeposits(deposits) + // If a local funding amount is set, coin-select deposits to // cover it. Otherwise fundmax uses all available deposits. if req.LocalFundingAmount != 0 { @@ -319,6 +323,14 @@ func (m *Manager) OpenChannel(ctx context.Context, } } + for _, d := range deposits { + // Deposited now includes mempool outputs for static loop-ins, but + // channel opens still require the deposit input to be confirmed. + if d.GetConfirmationHeight() <= 0 { + return nil, ErrOpeningChannelUnavailableDeposits + } + } + // Pre-check: calculate the channel funding amount and the optional // change before locking deposits. This ensures the selected deposits // can cover the funding amount plus fees. @@ -394,6 +406,22 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, err } +// filterConfirmedDeposits filters the given deposits and returns only those +// that have a positive confirmation height, i.e. deposits that have been +// confirmed on-chain. +func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit { + confirmed := make([]*deposit.Deposit, 0, len(deposits)) + for _, d := range deposits { + if d.GetConfirmationHeight() <= 0 { + continue + } + + confirmed = append(confirmed, d) + } + + return confirmed +} + // openChannelPsbt starts an interactive channel open protocol that uses a // partially signed bitcoin transaction (PSBT) to fund the channel output. The // protocol involves several steps between the loop client and the server: diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index a7bbbfc02..7250498c2 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -29,6 +29,7 @@ type transitionCall struct { } type mockDepositManager struct { + activeDeposits []*deposit.Deposit openingDeposits []*deposit.Deposit getErr error transitionErrs map[fsm.EventType]error @@ -44,15 +45,19 @@ func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) ( []*deposit.Deposit, error) { - if stateFilter != deposit.OpeningChannel { - return nil, nil - } + switch stateFilter { + case deposit.Deposited: + return m.activeDeposits, nil + + case deposit.OpeningChannel: + if m.getErr != nil { + return nil, m.getErr + } - if m.getErr != nil { - return nil, m.getErr + return m.openingDeposits, nil } - return m.openingDeposits, nil + return nil, nil } func (m *mockDepositManager) TransitionDeposits(_ context.Context, @@ -464,6 +469,97 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) { require.ErrorContains(t, err, "duplicate outpoint") } +// TestOpenChannelSkipsUnconfirmedAutoSelection verifies that automatic coin +// selection ignores mempool deposits and keeps using confirmed ones. +func TestOpenChannelSkipsUnconfirmedAutoSelection(t *testing.T) { + t.Parallel() + + confirmedA := &deposit.Deposit{ + OutPoint: testOutPoint(1), + Value: 160_000, + ConfirmationHeight: 10, + } + confirmedB := &deposit.Deposit{ + OutPoint: testOutPoint(2), + Value: 140_000, + ConfirmationHeight: 11, + } + unconfirmed := &deposit.Deposit{ + OutPoint: testOutPoint(3), + Value: 500_000, + } + + depositManager := &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + unconfirmed, confirmedA, confirmedB, + }, + transitionErrs: map[fsm.EventType]error{ + deposit.OnOpeningChannel: errors.New("stop after selection"), + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + }, + } + + req := &lnrpc.OpenChannelRequest{ + NodePubkey: make([]byte, 33), + LocalFundingAmount: 100_000, + SatPerVbyte: 10, + } + + _, err := manager.OpenChannel(context.Background(), req) + require.ErrorContains(t, err, "stop after selection") + require.Len(t, depositManager.calls, 1) + require.Equal(t, deposit.OnOpeningChannel, depositManager.calls[0].event) + require.NotContains(t, depositManager.calls[0].outpoints, unconfirmed.OutPoint) +} + +// TestOpenChannelFundMaxSkipsUnconfirmed verifies that fundmax only locks +// confirmed deposits. +func TestOpenChannelFundMaxSkipsUnconfirmed(t *testing.T) { + t.Parallel() + + confirmed := &deposit.Deposit{ + OutPoint: testOutPoint(1), + Value: 200_000, + ConfirmationHeight: 10, + } + unconfirmed := &deposit.Deposit{ + OutPoint: testOutPoint(2), + Value: 300_000, + } + + depositManager := &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + unconfirmed, confirmed, + }, + transitionErrs: map[fsm.EventType]error{ + deposit.OnOpeningChannel: errors.New("stop after selection"), + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + }, + } + + req := &lnrpc.OpenChannelRequest{ + NodePubkey: make([]byte, 33), + FundMax: true, + SatPerVbyte: 10, + } + + _, err := manager.OpenChannel(context.Background(), req) + require.ErrorContains(t, err, "stop after selection") + require.Len(t, depositManager.calls, 1) + require.Equal( + t, []wire.OutPoint{confirmed.OutPoint}, + depositManager.calls[0].outpoints, + ) +} + // TestValidateInitialPsbtFlags verifies that request fields incompatible with // PSBT funding are rejected early, before any deposits are locked. func TestValidateInitialPsbtFlags(t *testing.T) { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 22acb79ed..f7c8284f9 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -381,6 +381,15 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } } + for _, d := range deposits { + // Deposited now includes mempool outputs for static loop-ins, but + // withdrawals still require the deposit input to be confirmed. + if d.GetConfirmationHeight() <= 0 { + return "", "", fmt.Errorf("can't withdraw, " + + "unconfirmed deposits can't be withdrawn") + } + } + var ( withdrawalAddress btcutil.Address err error From 02a4bf73f03d5d5291c781e1dbfe09078a76f07f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:53:29 +0200 Subject: [PATCH 06/35] staticaddr/loopin: treat unconfirmed deposits as swappable Static address deposits with no confirmation height have not started their CSV timeout yet, so keep them eligible for loop-in selection instead of treating them as already near expiry. Prefer confirmed deposits before unconfirmed ones during automatic selection, and share the remaining-lifetime calculation used by the selector. --- staticaddr/loopin/manager.go | 63 +++++++++++++++++++++---------- staticaddr/loopin/manager_test.go | 27 +++++++++++++ 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 519695780..0b4ee28dc 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -845,11 +846,11 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( ) } -// SelectDeposits sorts the deposits by amount in descending order, then by -// blocks-until-expiry in ascending order. It then selects the deposits that -// are needed to cover the amount requested without leaving a dust change. It -// returns an error if the sum of deposits minus dust is less than the requested -// amount. +// SelectDeposits sorts deposits by confirmation status first, then by amount in +// descending order, then by blocks-until-expiry in ascending order. It then +// selects the deposits that are needed to cover the amount requested without +// leaving a dust change. It returns an error if the sum of deposits minus dust +// is less than the requested amount. func SelectDeposits(targetAmount btcutil.Amount, unfilteredDeposits []*deposit.Deposit, csvExpiry uint32, blockHeight uint32) ([]*deposit.Deposit, error) { @@ -871,14 +872,25 @@ func SelectDeposits(targetAmount btcutil.Amount, deposits = append(deposits, d) } - // Sort the deposits by amount in descending order, then by - // blocks-until-expiry in ascending order. + // Sort confirmed deposits ahead of unconfirmed ones so auto-selection + // prefers deposits the server can accept immediately. Within each group + // we prefer larger deposits, then earlier expiries. sort.Slice(deposits, func(i, j int) bool { + iConfirmationHeight := uint32(deposits[i].GetConfirmationHeight()) + jConfirmationHeight := uint32(deposits[j].GetConfirmationHeight()) + iConfirmed := iConfirmationHeight > 0 + jConfirmed := jConfirmationHeight > 0 + if iConfirmed != jConfirmed { + return iConfirmed + } + if deposits[i].Value == deposits[j].Value { - iExp := uint32(deposits[i].GetConfirmationHeight()) + - csvExpiry - blockHeight - jExp := uint32(deposits[j].GetConfirmationHeight()) + - csvExpiry - blockHeight + iExp := blocksUntilDepositExpiry( + iConfirmationHeight, blockHeight, csvExpiry, + ) + jExp := blocksUntilDepositExpiry( + jConfirmationHeight, blockHeight, csvExpiry, + ) return iExp < jExp } @@ -910,20 +922,33 @@ func SelectDeposits(targetAmount btcutil.Amount, // IsSwappable checks if a deposit is swappable. It returns true if the deposit // is not expired and the htlc is not too close to expiry. func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool { + if confirmationHeight == 0 { + return true + } + // The deposit expiry height is the confirmation height plus the csv // expiry. - depositExpiryHeight := confirmationHeight + csvExpiry + return blocksUntilDepositExpiry( + confirmationHeight, blockHeight, csvExpiry, + ) >= DefaultLoopInOnChainCltvDelta+DepositHtlcDelta +} - // The htlc expiry height is the current height plus the htlc - // cltv delta. - htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta +// blocksUntilDepositExpiry returns the remaining number of blocks until a +// deposit expires. Unconfirmed deposits return MaxUint32 because their CSV has +// not started yet. +func blocksUntilDepositExpiry(confirmationHeight, blockHeight, + csvExpiry uint32) uint32 { - // Ensure that the deposit doesn't expire before the htlc. - if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta { - return false + if confirmationHeight == 0 { + return math.MaxUint32 + } + + depositExpiryHeight := confirmationHeight + csvExpiry + if depositExpiryHeight <= blockHeight { + return 0 } - return true + return depositExpiryHeight - blockHeight } // DeduceSwapAmount calculates the swap amount based on the selected amount and diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index ed31df8ab..759acea03 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -81,6 +81,27 @@ func TestSelectDeposits(t *testing.T) { expected: []*deposit.Deposit{d3}, expectedErr: "", }, + { + name: "prefer confirmed deposit over larger unconfirmed one", + deposits: []*deposit.Deposit{ + { + Value: 2_000_000, + ConfirmationHeight: 0, + }, + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + targetValue: 1_000_000, + expected: []*deposit.Deposit{ + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + expectedErr: "", + }, { name: "single deposit insufficient by 1", deposits: []*deposit.Deposit{d1}, @@ -357,6 +378,12 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } +// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered +// swappable because its CSV timeout has not started yet. +func TestIsSwappableUnconfirmed(t *testing.T) { + require.True(t, IsSwappable(0, 5000, 1000)) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { // activeDeposits is the set returned by GetActiveDepositsInState. From c4aa29a77f8cbc143e4974772bb8ab4da2917394 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 6 Jul 2026 11:59:22 +0200 Subject: [PATCH 07/35] staticaddr/loopin: account for autoloop deposit expiry Use the shared deposit-expiry helper when building autoloop DP candidates so unconfirmed deposits do not look like the earliest-expiring options. This keeps the no-change selector's expiry tie-break aligned with the generic loop-in deposit selection rules. --- staticaddr/loopin/autoloop_dp.go | 6 ++++-- staticaddr/loopin/autoloop_dp_test.go | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go index 44dd12f49..be856df15 100644 --- a/staticaddr/loopin/autoloop_dp.go +++ b/staticaddr/loopin/autoloop_dp.go @@ -246,8 +246,10 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, continue } - residualLife := confirmationHeight + - int64(csvExpiry) - int64(blockHeight) + residualLife := int64(blocksUntilDepositExpiry( + uint32(confirmationHeight), + blockHeight, csvExpiry, + )) eligibleDeposits = append( eligibleDeposits, autoloopCandidateDeposit{ diff --git a/staticaddr/loopin/autoloop_dp_test.go b/staticaddr/loopin/autoloop_dp_test.go index c12536709..bf197ad7d 100644 --- a/staticaddr/loopin/autoloop_dp_test.go +++ b/staticaddr/loopin/autoloop_dp_test.go @@ -80,6 +80,28 @@ func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) { } } +// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits +// are not treated as earlier-expiring than confirmed deposits. Their CSV timer +// has not started yet, so a same-value confirmed deposit should win the expiry +// tie-break. +func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) { + t.Parallel() + + unconfirmed := makeDeposit(34, 0, 5_000, 0) + confirmed := makeDeposit(35, 0, 5_000, 200) + + deposits, err := selectNoChangeDeposits( + 5_000, 5_000, []*deposit.Deposit{ + unconfirmed, confirmed, + }, 1_000, 100, nil, + ) + require.NoError(t, err) + require.Equal( + t, []string{confirmed.OutPoint.String()}, + depositOutpoints(deposits), + ) +} + // TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier // to understand directly than by inferring the step from a larger selector // behavior test. From 04565b2f674e6b9d865b8abcd6ec5946d49659ac Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:55:04 +0200 Subject: [PATCH 08/35] staticaddr: refresh deposits before spend selection Refresh the active static-address deposit set against lnd's wallet view before quote, loop-in, withdrawal, channel-open, and autoloop selection paths. This prevents stale persisted Deposited records from being selected after replacement, reorg, or an external spend. --- loopd/swapclient_server.go | 18 +++++++++++++ loopd/swapclient_server_staticaddr_test.go | 30 ++++++++++++++++++++++ staticaddr/loopin/actions_test.go | 5 ++++ staticaddr/loopin/autoloop.go | 5 ++++ staticaddr/loopin/autoloop_dp.go | 3 +-- staticaddr/loopin/interface.go | 3 +++ staticaddr/loopin/manager.go | 15 ++++++++--- staticaddr/loopin/manager_test.go | 4 +++ staticaddr/openchannel/interface.go | 3 +++ staticaddr/openchannel/manager.go | 17 ++++++++---- staticaddr/openchannel/manager_test.go | 4 +++ staticaddr/withdraw/interface.go | 10 ++++++++ staticaddr/withdraw/manager.go | 10 +++++--- 13 files changed, 112 insertions(+), 15 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 44a0af0fb..5fe1236ce 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -946,6 +946,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, // number of deposits to quote for. numDeposits := 0 if autoSelectDeposits { + err = s.depositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", + err) + } + deposits, err := s.depositManager.GetActiveDepositsInState( deposit.Deposited, ) @@ -980,6 +986,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, numDeposits = len(selectedDeposits) } else if len(req.DepositOutpoints) > 0 { + err = s.depositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", + err) + } + // If deposits are selected, we need to retrieve them to // calculate the total value which we request a quote for. depositList, err := s.ListStaticAddressDeposits( @@ -1798,6 +1810,12 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context, return nil, fmt.Errorf("must select either all or some utxos") case isAllSelected: + err = s.depositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", + err) + } + deposits, err := s.depositManager.GetActiveDepositsInState( deposit.Deposited, ) diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 3d229a608..362734a78 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -13,6 +14,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" ) @@ -60,6 +62,33 @@ func (s *staticAddrDepositStore) AllDeposits(context.Context) ( return s.allDeposits, nil } +type staticAddrTestAddressManager struct{} + +func (s *staticAddrTestAddressManager) GetStaticAddressParameters( + context.Context) (*script.Parameters, error) { + + return nil, nil +} + +func (s *staticAddrTestAddressManager) GetStaticAddress( + context.Context) (*script.StaticAddress, error) { + + return nil, nil +} + +func (s *staticAddrTestAddressManager) ListUnspent(context.Context, + int32, int32) ([]*lnwallet.Utxo, error) { + + return nil, nil +} + +func (s *staticAddrTestAddressManager) GetTaprootAddress( + *btcec.PublicKey, *btcec.PublicKey, int64) (*btcutil.AddressTaproot, + error) { + + return nil, nil +} + // newTestDepositManager creates a deposit manager backed by seeded deposits. func newTestDepositManager( deposits ...*deposit.Deposit) *deposit.Manager { @@ -70,6 +99,7 @@ func newTestDepositManager( } return deposit.NewManager(&deposit.ManagerConfig{ + AddressManager: &staticAddrTestAddressManager{}, Store: &staticAddrDepositStore{ allDeposits: deposits, byOutpoint: byOutpoint, diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index d463bf37d..cb69b45dc 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -784,6 +784,11 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( // noopDepositManager is a stub DepositManager used to satisfy FSM config. type noopDepositManager struct{} +// EnsureDepositsFresh implements DepositManager with a no-op. +func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error { + return nil +} + // GetAllDeposits implements DepositManager with a no-op. func (n *noopDepositManager) GetAllDeposits(_ context.Context) ( []*deposit.Deposit, error) { diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go index 337d73e15..0bfddaa4f 100644 --- a/staticaddr/loopin/autoloop.go +++ b/staticaddr/loopin/autoloop.go @@ -30,6 +30,11 @@ func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context, return nil, 0, false, ErrNoAutoloopCandidate } + err := m.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, 0, false, err + } + allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( deposit.Deposited, ) diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go index be856df15..86e67fd19 100644 --- a/staticaddr/loopin/autoloop_dp.go +++ b/staticaddr/loopin/autoloop_dp.go @@ -247,8 +247,7 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, } residualLife := int64(blocksUntilDepositExpiry( - uint32(confirmationHeight), - blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, csvExpiry, )) eligibleDeposits = append( diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index 521390201..b50fd7a22 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -45,6 +45,9 @@ type AddressManager interface { // DepositManager handles the interaction of loop-ins with deposits. type DepositManager interface { + // EnsureDepositsFresh reconciles active deposits with the wallet view. + EnsureDepositsFresh(ctx context.Context) error + // GetAllDeposits returns all known deposits from the database store. GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 0b4ee28dc..92c4a9f14 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -630,6 +630,11 @@ func (m *Manager) initiateLoopIn(ctx context.Context, selectedDeposits []*deposit.Deposit ) + err = m.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", err) + } + // Determine which deposits to use for the loop-in swap. If none are // selected by the client, we will coin-select them based on the amount. switch { @@ -876,8 +881,8 @@ func SelectDeposits(targetAmount btcutil.Amount, // prefers deposits the server can accept immediately. Within each group // we prefer larger deposits, then earlier expiries. sort.Slice(deposits, func(i, j int) bool { - iConfirmationHeight := uint32(deposits[i].GetConfirmationHeight()) - jConfirmationHeight := uint32(deposits[j].GetConfirmationHeight()) + iConfirmationHeight := deposits[i].GetConfirmationHeight() + jConfirmationHeight := deposits[j].GetConfirmationHeight() iConfirmed := iConfirmationHeight > 0 jConfirmed := jConfirmationHeight > 0 if iConfirmed != jConfirmed { @@ -886,10 +891,12 @@ func SelectDeposits(targetAmount btcutil.Amount, if deposits[i].Value == deposits[j].Value { iExp := blocksUntilDepositExpiry( - iConfirmationHeight, blockHeight, csvExpiry, + uint32(iConfirmationHeight), blockHeight, + csvExpiry, ) jExp := blocksUntilDepositExpiry( - jConfirmationHeight, blockHeight, csvExpiry, + uint32(jConfirmationHeight), blockHeight, + csvExpiry, ) return iExp < jExp diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 759acea03..541f0826b 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -393,6 +393,10 @@ type mockDepositManager struct { byOutpoint map[string]*deposit.Deposit } +func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error { + return nil +} + func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( []*deposit.Deposit, error) { diff --git a/staticaddr/openchannel/interface.go b/staticaddr/openchannel/interface.go index 73010a2b3..ee6ed31a1 100644 --- a/staticaddr/openchannel/interface.go +++ b/staticaddr/openchannel/interface.go @@ -12,6 +12,9 @@ import ( ) type DepositManager interface { + // EnsureDepositsFresh reconciles active deposits with the wallet view. + EnsureDepositsFresh(ctx context.Context) error + // AllOutpointsActiveDeposits returns all deposits that are in the // given state. If the state filter is fsm.StateTypeNone, all deposits // are returned. diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 5128c168a..d06d1217f 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -267,11 +267,6 @@ func (m *Manager) OpenChannel(ctx context.Context, ).FeePerKWeight() } - // There are three ways in which we select deposits to open a channel - // with. 1.) The user manually selects the deposits. 2.) The user only - // selects a local channel amount in which case we coin-select deposits - // to cover for it. 3.) The user selects the fundmax flag, in which case - // we select all deposits to fund the channel. if len(req.Outpoints) > 0 { // Ensure that the deposits are in a state in which they are // available for a channel open. @@ -288,6 +283,12 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, fmt.Errorf("%w in request", err) } + err = m.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", + err) + } + deposits, allActive = m.cfg.DepositManager.AllOutpointsActiveDeposits( outpoints, deposit.Deposited, @@ -296,6 +297,12 @@ func (m *Manager) OpenChannel(ctx context.Context, return nil, ErrOpeningChannelUnavailableDeposits } } else { + err = m.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return nil, fmt.Errorf("unable to refresh deposits: %w", + err) + } + // We have to select the deposits that are used to fund the // channel. deposits, err = m.cfg.DepositManager.GetActiveDepositsInState( diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go index 7250498c2..d46c8c00b 100644 --- a/staticaddr/openchannel/manager_test.go +++ b/staticaddr/openchannel/manager_test.go @@ -36,6 +36,10 @@ type mockDepositManager struct { calls []transitionCall } +func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error { + return nil +} + func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, fsm.StateType) ([]*deposit.Deposit, bool) { diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index ff878488a..0f32697a9 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -21,14 +21,24 @@ type AddressManager interface { } type DepositManager interface { + // EnsureDepositsFresh reconciles active deposits with the wallet view. + EnsureDepositsFresh(ctx context.Context) error + + // GetActiveDepositsInState returns all active deposits in the given + // state. GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit, error) + // AllOutpointsActiveDeposits returns all active deposits referenced by + // the outpoints if every deposit is active and in the given state. AllOutpointsActiveDeposits(outpoints []wire.OutPoint, stateFilter fsm.StateType) ([]*deposit.Deposit, bool) + // TransitionDeposits transitions the deposits with the given event and + // waits until they reach the expected final state. TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit, event fsm.EventType, expectedFinalState fsm.StateType) error + // UpdateDeposit persists the current deposit fields. UpdateDeposit(ctx context.Context, d *deposit.Deposit) error } diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index f7c8284f9..3a7927a45 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -320,6 +320,11 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, allWithdrawing bool ) + err := m.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return "", "", fmt.Errorf("unable to refresh deposits: %w", err) + } + // Ensure that the deposits are in a state in which they can be // withdrawn. deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits( @@ -390,10 +395,7 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } } - var ( - withdrawalAddress btcutil.Address - err error - ) + var withdrawalAddress btcutil.Address // Check if the user provided an address to withdraw to. If not, we'll // generate a new address for them. From f483434c85034d752a9cf7ff1df31407fb4175ad Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:55:32 +0200 Subject: [PATCH 09/35] loopd: filter static deposit views through active set Use the deposit manager's visible-deposit view for normal list and summary RPCs so historical Deposited rows whose outpoints vanished from lnd's wallet view are not exposed as available funds. --- loopd/swapclient_server.go | 12 ++-- loopd/swapclient_server_staticaddr_test.go | 73 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 5fe1236ce..d89dbbce2 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1022,9 +1022,11 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, // server can probe the selected value and calculate the per // input fee. for _, deposit := range depositList.FilteredDeposits { - // For a manual quote we require the current state to be - // Deposited so a stale client-side outpoint selection - // fails early instead of making it to swap initiation. + // ListStaticAddressDeposits only returns deposits that are visible + // in the manager's live view. For a manual quote we additionally + // require the current state to be Deposited so stale client-side + // outpoint selection fails early instead of making it to swap + // initiation. if deposit.State != looprpc.DepositState_DEPOSITED { return nil, fmt.Errorf("deposit %s is not "+ "currently available", deposit.Outpoint) @@ -1882,7 +1884,7 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, "outpoints") } - allDeposits, err := s.depositManager.GetAllDeposits(ctx) + allDeposits, err := s.depositManager.GetVisibleDeposits(ctx) if err != nil { return nil, err } @@ -2119,7 +2121,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, _ *looprpc.StaticAddressSummaryRequest) ( *looprpc.StaticAddressSummaryResponse, error) { - allDeposits, err := s.depositManager.GetAllDeposits(ctx) + allDeposits, err := s.depositManager.GetVisibleDeposits(ctx) if err != nil { return nil, err } diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 362734a78..bb4cc01ca 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -136,6 +136,79 @@ func newTestStaticAddressContext(t *testing.T) (*address.Manager, return addrMgr, mock } +// TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit +// listings include visible deposit records. +func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { + t.Parallel() + + available := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + } + available.SetState(deposit.Deposited) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(available), + staticAddressManager: addrMgr, + lnd: &lnd.LndServices, + } + + resp, err := server.ListStaticAddressDeposits( + context.Background(), &looprpc.ListStaticAddressDepositsRequest{}, + ) + require.NoError(t, err) + require.Len(t, resp.FilteredDeposits, 1) + require.Equal( + t, available.OutPoint.String(), + resp.FilteredDeposits[0].Outpoint, + ) +} + +// TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are +// included in static address summary totals. +func TestGetStaticAddressSummaryTotalsDeposits(t *testing.T) { + t.Parallel() + + unconfirmed := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 4, + }, + Value: btcutil.Amount(2_000), + ConfirmationHeight: 0, + } + unconfirmed.SetState(deposit.Deposited) + + confirmed := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{5}, + Index: 5, + }, + Value: btcutil.Amount(3_000), + ConfirmationHeight: 123, + } + confirmed.SetState(deposit.Deposited) + + addrMgr, _ := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager( + unconfirmed, confirmed, + ), + staticAddressManager: addrMgr, + } + + resp, err := server.GetStaticAddressSummary( + context.Background(), &looprpc.StaticAddressSummaryRequest{}, + ) + require.NoError(t, err) + require.EqualValues(t, 2, resp.TotalNumDeposits) + require.EqualValues(t, 2_000, resp.ValueUnconfirmedSatoshis) + require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis) +} + // TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote // requests fail for selected deposits that are no longer available. func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) { From 32ac85aee8e4fd9066e984791a05c4ecf351af9a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:55:50 +0200 Subject: [PATCH 10/35] staticaddr/loopin: cancel signing for unavailable deposits Check the originally selected deposit outpoints before signing a static loop-in HTLC transaction. If any selected outpoint is no longer available, cancel the swap invoice and fail the signing action instead of producing signatures for stale inputs. --- staticaddr/loopin/actions.go | 52 +++++++++++- staticaddr/loopin/actions_test.go | 136 +++++++++++++++++++++++++++++- test/lightning_client_mock.go | 4 +- test/lnd_services_mock.go | 9 ++ 4 files changed, 195 insertions(+), 6 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index bfb3d333c..8bb35fcf3 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -344,7 +344,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, // cancelSwapInvoice best-effort cancels the current swap invoice using a // detached timeout-limited context. func (f *FSM) cancelSwapInvoice() { - if f.loopIn.SwapInvoice == "" { + if f.loopIn.SwapHash == (lntypes.Hash{}) { return } @@ -389,6 +389,44 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( } } +// originalDepositOutpointUnavailable checks the original selected deposit +// outpoints against the chain backend's UTXO view. +func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( + bool, error) { + + if f.cfg.TxOutChecker == nil { + return false, nil + } + + if len(f.loopIn.DepositOutpoints) == 0 { + return false, nil + } + + outpoints := make([]wire.OutPoint, len(f.loopIn.DepositOutpoints)) + for i, outpointStr := range f.loopIn.DepositOutpoints { + outpoint, err := wire.NewOutPointFromString(outpointStr) + if err != nil { + return false, fmt.Errorf("invalid deposit outpoint %q: %w", + outpointStr, err) + } + + outpoints[i] = *outpoint + } + + txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints) + if err != nil { + return false, fmt.Errorf("unable to get txouts: %w", err) + } + + for _, outpoint := range outpoints { + if txOuts[outpoint] == nil { + return true, nil + } + } + + return false, nil +} + // SignHtlcTxAction is called if the htlc was initialized and the server // provided the necessary information to construct the htlc tx. We sign the htlc // tx and send the signatures to the server. @@ -397,6 +435,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, var err error + outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx) + if err != nil { + return f.HandleError(err) + } + if outpointUnavailable { + err = errors.New("original deposit outpoint no longer available") + f.Warnf("%v, canceling swap invoice", err) + f.cancelSwapInvoice() + + return f.HandleError(err) + } + f.loopIn.AddressParams, err = f.cfg.AddressManager.GetStaticAddressParameters(ctx) diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index cb69b45dc..43f2890a6 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -133,10 +133,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { loopIn.SetState(MonitorInvoiceAndHtlcTx) // Seed the mock invoice store so LookupInvoice succeeds. - mockLnd.Invoices[swapHash] = &lndclient.Invoice{ + mockLnd.SetInvoice(&lndclient.Invoice{ Hash: swapHash, State: invoices.ContractOpen, - } + }) cfg := &Config{ AddressManager: &mockAddressManager{ @@ -451,8 +451,7 @@ func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { TxOutChecker: checker, }, loopIn: &StaticAddressLoopIn{ - Deposits: []*deposit.Deposit{dep}, - DepositOutpoints: []string{dep.OutPoint.String()}, + Deposits: []*deposit.Deposit{dep}, }, } @@ -575,6 +574,135 @@ func testValidateLoopInContract(_ int32, _ int32) error { return nil } +// TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a +// present txout does not trigger the RBF cancellation path. +func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) { + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + } + + txOutChecker := &recordingTxOutChecker{ + txOuts: map[wire.OutPoint]*wire.TxOut{ + originalOutpoint: {Value: 10_000}, + }, + } + f := &FSM{ + cfg: &Config{ + TxOutChecker: txOutChecker, + }, + loopIn: &StaticAddressLoopIn{ + DepositOutpoints: []string{originalOutpoint.String()}, + }, + } + + unavailable, err := f.originalDepositOutpointUnavailable(t.Context()) + require.NoError(t, err) + require.False(t, unavailable) + require.Equal(t, [][]wire.OutPoint{{originalOutpoint}}, + txOutChecker.outpoints) +} + +// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a +// pending loop-in is canceled before HTLC signing if GetTxOuts reports that +// one of the originally selected outpoints is gone. +func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + swapHash := lntypes.Hash{9, 8, 7} + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + DepositOutpoints: []string{originalOutpoint.String()}, + } + + txOutChecker := &recordingTxOutChecker{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + event := f.SignHtlcTxAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, "original deposit outpoint no longer available", + ) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + require.Equal(t, [][]wire.OutPoint{{originalOutpoint}}, + txOutChecker.outpoints) +} + +// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup +// failures are treated as errors, but do not cancel the invoice. The invoice is +// only canceled when GetTxOuts omits an original outpoint. +func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + swapHash := lntypes.Hash{9, 8, 6} + originalOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + DepositOutpoints: []string{originalOutpoint.String()}, + } + + txOutChecker := &recordingTxOutChecker{ + err: errors.New("backend unavailable"), + } + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + event := f.SignHtlcTxAction(ctx, nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, "unable to get txout", + ) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %x", hash) + default: + } +} + // TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice // created before a server-side rejection is canceled immediately. func TestInitHtlcActionCancelsInvoiceOnServerError(t *testing.T) { diff --git a/test/lightning_client_mock.go b/test/lightning_client_mock.go index 27f3fada3..350fb59a0 100644 --- a/test/lightning_client_mock.go +++ b/test/lightning_client_mock.go @@ -155,7 +155,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context, return nil, fmt.Errorf("invoice: %x not found", hash) } - return inv, nil + invoiceCopy := *inv + + return &invoiceCopy, nil } // ListTransactions returns all known transactions of the backing lnd node. diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index 63aa34c26..a41a3dbc8 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -225,6 +225,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) { s.lock.Unlock() } +// SetInvoice stores a copy of the given invoice in the mock invoice store. +func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) { + s.lock.Lock() + defer s.lock.Unlock() + + invoiceCopy := *invoice + s.Invoices[invoice.Hash] = &invoiceCopy +} + // IsDone checks whether all channels have been fully emptied. If not this may // indicate unexpected behaviour of the code under test. func (s *LndMockServices) IsDone() error { From 0b8017e5c92fa11344ac50ba4b446cc23e628caf Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:56:11 +0200 Subject: [PATCH 11/35] notifications: fan out static loop-in risk decisions Add cached per-swap notification fanout for static loop-in confirmation-risk acceptance and rejection notifications so loop-in FSMs can subscribe by swap hash and receive decisions that arrived before subscription. --- notifications/manager.go | 185 +++++++++++++++++++++++++ notifications/manager_test.go | 254 ++++++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) diff --git a/notifications/manager.go b/notifications/manager.go index 2ff6b087b..ed408195d 100644 --- a/notifications/manager.go +++ b/notifications/manager.go @@ -26,6 +26,14 @@ const ( // static loop in sweep requests. NotificationTypeStaticLoopInSweepRequest + // NotificationTypeStaticLoopInRiskAccepted is the notification type for + // static loop in confirmation risk acceptance. + NotificationTypeStaticLoopInRiskAccepted + + // NotificationTypeStaticLoopInRiskRejected is the notification type for + // static loop in confirmation risk rejection. + NotificationTypeStaticLoopInRiskRejected + // NotificationTypeUnfinishedSwap is the notification type for unfinished // swap notifications. NotificationTypeUnfinishedSwap @@ -92,6 +100,12 @@ type Manager struct { hasL402 bool subscribers map[NotificationType][]subscriber + + staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification + + staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskRejectedNotification } // NewManager creates a new notification manager. @@ -107,6 +121,14 @@ func NewManager(cfg *Config) *Manager { return &Manager{ cfg: cfg, subscribers: make(map[NotificationType][]subscriber), + staticLoopInRiskAccepted: make( + map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, + ), + staticLoopInRiskRejected: make( + map[lntypes.Hash]*swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, + ), } } @@ -114,6 +136,7 @@ type subscriber struct { subCtx context.Context recvChan any enqueue func(any) + swapHash *lntypes.Hash } // newNotificationQueue creates a per-subscriber FIFO delivery function. @@ -271,6 +294,80 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context, return notifChan } +// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted +// notifications. +func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, + swapHash lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { + + notifChan := make( + chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1, + ) + + sub := subscriber{ + subCtx: ctx, + recvChan: notifChan, + swapHash: &swapHash, + } + + m.Lock() + m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append( + m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub, + ) + if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok { + notifChan <- ntfn + delete(m.staticLoopInRiskAccepted, swapHash) + } + m.Unlock() + + context.AfterFunc(ctx, func() { + m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub) + m.Lock() + delete(m.staticLoopInRiskAccepted, swapHash) + m.Unlock() + close(notifChan) + }) + + return notifChan +} + +// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected +// notifications. +func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context, + swapHash lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { + + notifChan := make( + chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1, + ) + + sub := subscriber{ + subCtx: ctx, + recvChan: notifChan, + swapHash: &swapHash, + } + + m.Lock() + m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append( + m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub, + ) + if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok { + notifChan <- ntfn + delete(m.staticLoopInRiskRejected, swapHash) + } + m.Unlock() + + context.AfterFunc(ctx, func() { + m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub) + m.Lock() + delete(m.staticLoopInRiskRejected, swapHash) + m.Unlock() + close(notifChan) + }) + + return notifChan +} + // SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications. func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context, ) <-chan *swapserverrpc.ServerUnfinishedSwapNotification { @@ -476,6 +573,94 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn) } + case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll + // We'll forward the static loop in risk accepted notification to the + // subscriber for the matching swap. + riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted() + m.Lock() + defer m.Unlock() + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if riskAcceptedNtfn != nil { + hash, err := lntypes.MakeHash(riskAcceptedNtfn.SwapHash) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "accepted notification: %v", err) + } else { + swapHash = hash + hasSwapHash = true + m.staticLoopInRiskAccepted[hash] = + riskAcceptedNtfn + delete(m.staticLoopInRiskRejected, hash) + } + } + + for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskAccepted] { // nolint: lll + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification) + + select { + case recvChan <- riskAcceptedNtfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping static loop in risk " + + "accepted notification for slow subscriber") + } + } + + case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll + // We'll forward the static loop in risk rejected notification to the + // subscriber for the matching swap. + riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected() + m.Lock() + defer m.Unlock() + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if riskRejectedNtfn != nil { + hash, err := lntypes.MakeHash(riskRejectedNtfn.SwapHash) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "rejected notification: %v", err) + } else { + swapHash = hash + hasSwapHash = true + m.staticLoopInRiskRejected[hash] = + riskRejectedNtfn + delete(m.staticLoopInRiskAccepted, hash) + } + } + + for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskRejected] { // nolint: lll + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification) + + select { + case recvChan <- riskRejectedNtfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping static loop in risk " + + "rejected notification for slow subscriber") + } + } + case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll // We'll forward the unfinished swap notification to all // subscribers. diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 1768b0046..b165c494b 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -220,6 +220,88 @@ func staticLoopInSweepNotification( } } +func staticLoopInRiskAcceptedNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + } +} + +// staticLoopInRiskRejectedNotification builds a risk rejected notification. +func staticLoopInRiskRejectedNotification( + swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { + + return &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + } +} + +type staticLoopInRiskNotification interface { + GetSwapHash() []byte +} + +// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout. +func assertStaticLoopInRiskNotificationSwapScoped[ + T staticLoopInRiskNotification](t *testing.T, + subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T, + notification func(lntypes.Hash) *swapserverrpc. + SubscribeNotificationsResponse, label string, + swapHashA, swapHashB lntypes.Hash) { + + t.Helper() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChanA := subscribe(mgr, subCtx, swapHashA) + subChanB := subscribe(mgr, subCtx, swapHashB) + + mgr.handleNotification(notification(swapHashA)) + + select { + case received := <-subChanA: + require.Equal(t, swapHashA[:], received.GetSwapHash()) + + case <-time.After(time.Second): + t.Fatalf("did not receive first swap risk %s notification", + label) + } + + select { + case received := <-subChanB: + t.Fatalf("second swap received wrong notification: %x", + received.GetSwapHash()) + + default: + } + + mgr.handleNotification(notification(swapHashB)) + + select { + case received := <-subChanB: + require.Equal(t, swapHashB[:], received.GetSwapHash()) + + case <-time.After(time.Second): + t.Fatalf("did not receive second swap risk %s notification", + label) + } +} + // TestManager_SlowReservationSubscriberDoesNotBlock tests that a reservation // subscriber with a full notification channel does not block delivery to other // subscribers. Reservation notifications are best-effort, so slow subscribers @@ -460,6 +542,178 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T, } } +// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager +// forwards static loop in risk accepted notifications to subscribers. +func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + swapHash := lntypes.Hash{0x04, 0x05} + + subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) + + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not receive risk accepted notification") + } +} + +// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a +// notification for one swap does not occupy another swap's subscriber channel. +func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) { + t.Parallel() + + assertStaticLoopInRiskNotificationSwapScoped( + t, func(m *Manager, ctx context.Context, + swapHash lntypes.Hash) <-chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification { + + return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash) + }, staticLoopInRiskAcceptedNotification, "accepted", + lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07}, + ) +} + +// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager +// replays a risk accepted notification that arrives before the swap-specific +// subscriber is registered. +func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + swapHash := lntypes.Hash{0x06, 0x07} + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ + StaticLoopInRiskAccepted: &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not replay risk accepted notification") + } +} + +// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager +// forwards static loop in risk rejected notifications to subscribers. +func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + swapHash := lntypes.Hash{0x08, 0x09} + + subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) + + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not receive risk rejected notification") + } +} + +// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a +// notification for one swap does not occupy another swap's subscriber channel. +func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) { + t.Parallel() + + assertStaticLoopInRiskNotificationSwapScoped( + t, func(m *Manager, ctx context.Context, + swapHash lntypes.Hash) <-chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification { + + return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash) + }, staticLoopInRiskRejectedNotification, "rejected", + lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b}, + ) +} + +// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager +// replays a risk rejected notification that arrives before the swap-specific +// subscriber is registered. +func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) { + t.Parallel() + + mgr := NewManager(&Config{}) + + swapHash := lntypes.Hash{0x0a, 0x0b} + mgr.handleNotification( + &swapserverrpc.SubscribeNotificationsResponse{ + Notification: &swapserverrpc. + SubscribeNotificationsResponse_StaticLoopInRiskRejected{ + StaticLoopInRiskRejected: &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + }, + }, + }, + ) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not replay risk rejected notification") + } +} + // TestManager_Backoff verifies that repeated failures in // subscribeNotifications cause the Manager to space out subscription attempts // via a predictable incremental backoff. From 4553de430e8415798c4bd60d3a135dcdd147f2c1 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:56:27 +0200 Subject: [PATCH 12/35] staticaddr/loopin: wait for risk decisions Subscribe to static loop-in confirmation-risk notifications before starting the payment deadline. Start that deadline only after server acceptance or the legacy confirmation fallback, and cancel the swap invoice when the server rejects the risk wait. Refresh selected deposits before the legacy fallback so recovered monitors use current confirmation heights. --- staticaddr/loopin/actions.go | 254 ++++++- staticaddr/loopin/actions_test.go | 1089 ++++++++++++++++++++++++++++- staticaddr/loopin/interface.go | 14 + 3 files changed, 1326 insertions(+), 31 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 8bb35fcf3..6111469d2 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -389,6 +390,119 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( } } +// selectedDepositConfirmationHeights returns current confirmation heights for +// the original deposit outpoints selected by this loop-in. +func selectedDepositConfirmationHeights( + loopIn *StaticAddressLoopIn) map[string]int64 { + + confirmations := make(map[string]int64, len(loopIn.Deposits)) + outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints)) + for _, outpoint := range loopIn.DepositOutpoints { + outpoints[outpoint] = struct{}{} + } + + for _, d := range loopIn.Deposits { + if d == nil { + continue + } + + outpoint := d.OutPoint.String() + confirmationHeight := d.GetConfirmationHeight() + + if _, ok := outpoints[outpoint]; !ok { + continue + } + + confirmations[outpoint] = confirmationHeight + } + + return confirmations +} + +// refreshSelectedDeposits reloads the loop-in's selected deposits from the +// deposit manager/store so recovery does not rely on stale deposit snapshots. +func (f *FSM) refreshSelectedDeposits(ctx context.Context) error { + if f.cfg.DepositManager == nil || len(f.loopIn.DepositOutpoints) == 0 { + return nil + } + + err := f.cfg.DepositManager.EnsureDepositsFresh(ctx) + if err != nil { + return fmt.Errorf("unable to refresh deposit wallet view: %w", err) + } + + const ignoreUnknownOutpoints = false + deposits, err := f.cfg.DepositManager.DepositsForOutpoints( + ctx, f.loopIn.DepositOutpoints, ignoreUnknownOutpoints, + ) + if err != nil { + return err + } + + if len(deposits) != len(f.loopIn.DepositOutpoints) { + return fmt.Errorf("expected %d selected deposits, got %d", + len(f.loopIn.DepositOutpoints), len(deposits)) + } + + f.loopIn.Deposits = deposits + + return nil +} + +// legacyMinConfsReached returns true once every original deposit is confirmed +// and the youngest original deposit has reached the legacy confirmation target. +func legacyMinConfsReached(outpoints []string, + confirmationHeights map[string]int64, currentHeight int32) bool { + + if currentHeight <= 0 || len(outpoints) == 0 { + return false + } + + youngestConfirmation := int64(0) + for _, outpoint := range outpoints { + confirmationHeight, ok := confirmationHeights[outpoint] + if !ok || confirmationHeight <= 0 { + return false + } + + if confirmationHeight > youngestConfirmation { + youngestConfirmation = confirmationHeight + } + } + + return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1 +} + +// shouldStartLegacyConfirmationFallback reports whether the local MinConfs +// payment deadline fallback should be armed at the current block height. +// +// The primary path starts the deadline from a server risk-accepted notification. +// This fallback preserves the legacy client-side MinConfs behavior when no risk +// decision has been observed locally: once every original deposit reaches +// MinConfs, the client treats that as enough confirmation-risk clearance to +// start the payment window. The selected deposits are refreshed first so +// recovered swaps do not depend on stale in-memory deposit snapshots. +func (f *FSM) shouldStartLegacyConfirmationFallback(ctx context.Context, + currentHeight int32) bool { + + err := f.refreshSelectedDeposits(ctx) + if err != nil { + f.Warnf("unable to refresh selected deposits for legacy "+ + "confirmation fallback: %v", err) + + return false + } + + depositConfirmationHeights := selectedDepositConfirmationHeights( + f.loopIn, + ) + + return legacyMinConfsReached( + f.loopIn.DepositOutpoints, depositConfirmationHeights, + currentHeight, + ) +} + // originalDepositOutpointUnavailable checks the original selected deposit // outpoints against the chain backend's UTXO view. func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( @@ -737,6 +851,27 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } + var ( + riskAcceptedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification + riskRejectedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification + cancelRiskNotificationSubscriptions = func() {} + ) + if f.cfg.NotificationManager != nil { + notificationCtx, cancel := context.WithCancel(ctx) + cancelRiskNotificationSubscriptions = cancel + riskAcceptedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskAccepted( + notificationCtx, f.loopIn.SwapHash, + ) + riskRejectedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskRejected( + notificationCtx, f.loopIn.SwapHash, + ) + } + defer cancelRiskNotificationSubscriptions() + // Look up the current invoice state after registering subscriptions so // recovery can resume the payment deadline from the latest known state. invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) @@ -751,30 +886,34 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } - // Create the swap payment timeout timer. If it runs out we cancel the - // invoice, but keep monitoring the htlc confirmation. - // If the invoice was canceled, e.g. before a restart, we don't need to - // set a new deadline. - var deadlineChan <-chan time.Time - if invoice.State != invoices.ContractCanceled { - // If the invoice is still live we set the timeout to the - // remaining payment time. If too much time has elapsed, e.g. - // after a restart, we cancel the invoice immediately and keep - // monitoring the HTLC until it can no longer confirm. - remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds() - - // If the invoice isn't cancelled yet and the payment timeout - // elapsed, we set the timeout to 0 to cancel the invoice and - // unlock the deposits immediately. Otherwise, we start the - // timer with the remaining seconds to timeout. - timeout := time.Duration(0) * time.Second - if remainingTimeSeconds > 0 { - timeout = time.Duration(remainingTimeSeconds) * - time.Second + // Create the swap payment timeout timer after the server confirms + // confirmation risk was accepted. If a server does not support risk + // notifications, fall back after the legacy deposit confirmation depth. + var ( + deadlineChan <-chan time.Time + deadlineTimer *time.Timer + deadlineStarted bool + ) + defer func() { + if deadlineTimer != nil { + deadlineTimer.Stop() + } + }() + + startPaymentDeadline := func(reason string) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return } - deadlineChan = time.NewTimer(timeout).C - } else { + timeout := f.loopIn.PaymentTimeoutDuration() + + f.Infof("starting payment deadline after %s", reason) + deadlineTimer = time.NewTimer(timeout) + deadlineChan = deadlineTimer.C + deadlineStarted = true + } + + if invoice.State == invoices.ContractCanceled { // If the invoice was canceled previously we end our // subscription to invoice updates. cancelInvoiceSubscription() @@ -844,6 +983,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } case <-deadlineChan: + deadlineChan = nil + // If the server didn't pay the invoice on time, we // cancel the invoice and keep monitoring the htlc tx // confirmation. We also need to unlock the deposits to @@ -856,7 +997,76 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, "payment deadline: %v", err) } + case riskAccepted, ok := <-riskAcceptedChan: + if !ok { + riskAcceptedChan = nil + continue + } + + if !bytes.Equal( + riskAccepted.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + startPaymentDeadline("risk accepted notification") + + case riskRejected, ok := <-riskRejectedChan: + if !ok { + riskRejectedChan = nil + continue + } + + if !bytes.Equal( + riskRejected.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + cancelInvoiceSubscription() + f.cancelSwapInvoice() + + return f.HandleError(errors.New( + "server rejected confirmation risk wait", + )) + case currentHeight := <-blockChan: + if !deadlineStarted && + invoice.State != invoices.ContractCanceled { + + err = f.refreshSelectedDeposits(ctx) + if err != nil { + f.Warnf("unable to refresh selected "+ + "deposits for legacy confirmation "+ + "fallback: %v", err) + } else { + depositConfirmationHeights := + selectedDepositConfirmationHeights( + f.loopIn, + ) + + if legacyMinConfsReached( + f.loopIn.DepositOutpoints, + depositConfirmationHeights, + currentHeight, + ) { + + // This fallback is a compatibility path for + // servers that do not send confirmation-risk + // notifications. Reaching legacy MinConfs is + // treated as synthetic risk acceptance, so the + // payment window starts here just as it would + // when a modern server sends an acceptance + // notification. + startPaymentDeadline( + "legacy confirmation fallback", + ) + } + } + } + // If the htlc is confirmed but blockChan fires before // htlcConfChan, we would wrongfully assume that the // htlc tx was not confirmed which would lead to diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 43f2890a6..2f8cdb724 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -574,6 +574,1013 @@ func testValidateLoopInContract(_ int32, _ int32) error { return nil } +// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the +// payment timeout does not start until the server notifies us that confirmation +// risk was accepted. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{4, 5, 6} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + } + depositMgr := &recordingDepositManager{ + transitionChan: make(chan depositTransition, 1), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before risk acceptance: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc.ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after risk acceptance: %v", + hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case transition := <-depositMgr.transitionChan: + require.Equal(t, []*deposit.Deposit{ + loopIn.Deposits[0], + }, transition.deposits) + require.Equal(t, fsm.OnError, transition.event) + require.Equal(t, deposit.Deposited, transition.state) + + case <-ctx.Done(): + t.Fatalf("deposits were not unlocked: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected verifies that a server-side +// confirmation risk rejection is terminal for the client monitor action. +func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 7} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskRejected: make( + chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + notificationMgr.riskRejected <- &swapserverrpc.ServerStaticLoopInRiskRejectedNotification{ // nolint: lll + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, + "server rejected confirmation risk wait", + ) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes +// verifies that once the monitor state is reached, a missing original deposit +// outpoint does not cancel the invoice. After HTLC signatures are handed to the +// server, the outpoint can disappear because the server published the expected +// HTLC transaction. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 7, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + txOutChecker := &recordingTxOutChecker{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } + + require.Empty(t, txOutChecker.outpoints) +} + +// TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint +// verifies that the outpoint-vanished fallback is only active before payment +// has started. Once the invoice is accepted, the original deposit may disappear +// because the server has moved forward with the swap. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{6, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{11}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractAccepted, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: &recordingTxOutChecker{}, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case <-resultChan: + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs verifies that the +// monitor action preserves the legacy payment deadline fallback when no risk +// decision has been observed locally. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{depositRecord}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before deposit confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager +// verifies that the legacy payment deadline fallback still applies when the +// notification manager is configured but no risk decision has been observed. +func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, + 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{depositRecord}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before payment deadline: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight verifies that +// recovery can arm the legacy payment deadline without waiting for a later +// block notification. +func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 12} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + Index: 0, + } + staleDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 0, + } + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + freshDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: confirmationHeight, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{staleDeposit}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: &silentBlockChainNotifier{ + ChainNotifierClient: mockLnd.ChainNotifier, + }, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{freshDeposit}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before payment deadline: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback verifies that a +// recovered monitor state does not rely on stale selected-deposit snapshots when +// deciding whether the legacy payment deadline fallback has opened. +func TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 11} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + Index: 0, + } + staleDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 0, + } + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + freshDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: confirmationHeight, + } + type depositLookup struct { + outpoints []string + ignoreUnknown bool + } + depositLookups := make(chan depositLookup, 1) + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{staleDeposit}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{freshDeposit}, + depositsForOutpoints: func(outpoints []string, + ignoreUnknown bool) { + + select { + case depositLookups <- depositLookup{ + outpoints: append( + []string(nil), outpoints..., + ), + ignoreUnknown: ignoreUnknown, + }: + default: + } + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after deposit "+ + "confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case lookup := <-depositLookups: + require.Equal(t, []string{ + depositOutpoint.String(), + }, lookup.outpoints) + require.False(t, lookup.ignoreUnknown) + + case <-ctx.Done(): + t.Fatalf("deposit refresh was not called: %v", ctx.Err()) + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestLegacyConfirmationFallbackStopsOnFreshnessFailure verifies that MinConfs +// is not evaluated from cached deposits when the wallet reconciliation fails. +func TestLegacyConfirmationFallbackStopsOnFreshnessFailure(t *testing.T) { + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{14}, + Index: 0, + } + lookupCalled := false + depositManager := &noopDepositManager{ + deposits: []*deposit.Deposit{{ + OutPoint: outpoint, + ConfirmationHeight: 1, + }}, + ensureFreshErr: errors.New("wallet unavailable"), + depositsForOutpoints: func([]string, bool) { + lookupCalled = true + }, + } + f := &FSM{ + cfg: &Config{ + DepositManager: depositManager, + }, + loopIn: &StaticAddressLoopIn{ + DepositOutpoints: []string{outpoint.String()}, + }, + } + + reached := f.shouldStartLegacyConfirmationFallback( + t.Context(), deposit.MinConfs, + ) + require.False(t, reached) + require.False(t, lookupCalled) +} + +// TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline verifies that +// deposits are unlocked even if the payment deadline never started before the +// HTLC timeout path opened. +func TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{10, 11, 12} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: mockLnd.Height, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, OnSwapTimedOut, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events) + require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states) +} + +// waitForMonitorSubscriptions waits until invoice and HTLC watchers are active. +func waitForMonitorSubscriptions(t *testing.T, ctx context.Context, + mockLnd *test.LndMockServices) { + + t.Helper() + + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } +} + // TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a // present txout does not trigger the RBF cancellation path. func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) { @@ -910,11 +1917,16 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( } // noopDepositManager is a stub DepositManager used to satisfy FSM config. -type noopDepositManager struct{} +type noopDepositManager struct { + deposits []*deposit.Deposit + depositsForOutpoints func([]string, bool) + depositErr error + ensureFreshErr error +} // EnsureDepositsFresh implements DepositManager with a no-op. func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error { - return nil + return n.ensureFreshErr } // GetAllDeposits implements DepositManager with a no-op. @@ -939,10 +1951,14 @@ func (n *noopDepositManager) TransitionDeposits(context.Context, } // DepositsForOutpoints implements DepositManager with a no-op. -func (n *noopDepositManager) DepositsForOutpoints(context.Context, []string, - bool) ([]*deposit.Deposit, error) { +func (n *noopDepositManager) DepositsForOutpoints(_ context.Context, + outpoints []string, ignoreUnknown bool) ([]*deposit.Deposit, error) { - return nil, nil + if n.depositsForOutpoints != nil { + n.depositsForOutpoints(outpoints, ignoreUnknown) + } + + return n.deposits, n.depositErr } // GetActiveDepositsInState implements DepositManager with a no-op. @@ -961,8 +1977,12 @@ type depositTransition struct { type recordingDepositManager struct { noopDepositManager - err error - transitions []depositTransition + err error + transitions []depositTransition + transitionChan chan depositTransition + + events []fsm.EventType + states []fsm.StateType } // TransitionDeposits records the transition and returns the configured error. @@ -970,15 +1990,66 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, deposits []*deposit.Deposit, event fsm.EventType, state fsm.StateType) error { - r.transitions = append(r.transitions, depositTransition{ + transition := depositTransition{ deposits: deposits, event: event, state: state, - }) + } + + r.transitions = append(r.transitions, transition) + r.events = append(r.events, event) + r.states = append(r.states, state) + + if r.transitionChan != nil { + r.transitionChan <- transition + } return r.err } +// mockNotificationManager allows tests to push server notifications directly to +// monitor actions. +type mockNotificationManager struct { + riskAccepted chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + riskRejected chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification +} + +type silentBlockChainNotifier struct { + lndclient.ChainNotifierClient +} + +// RegisterBlockEpochNtfn implements ChainNotifierClient without delivering an +// initial block. Tests use it to assert current-height recovery behavior without +// relying on a block notification. +func (s *silentBlockChainNotifier) RegisterBlockEpochNtfn(context.Context) ( + chan int32, chan error, error) { + + return make(chan int32), make(chan error), nil +} + +// SubscribeStaticLoopInSweepRequests implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInSweepRequests( + context.Context) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification { + + return make(chan *swapserverrpc.ServerStaticLoopInSweepNotification) +} + +// SubscribeStaticLoopInRiskAccepted implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskAccepted( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { + + return m.riskAccepted +} + +// SubscribeStaticLoopInRiskRejected implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskRejected( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { + + return m.riskRejected +} + type recordingTxOutChecker struct { outpoints [][]wire.OutPoint txOuts map[wire.OutPoint]*wire.TxOut diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index b50fd7a22..ab8dc45f7 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -124,4 +124,18 @@ type NotificationManager interface { // a sweep of a static loop in that has been finished. SubscribeStaticLoopInSweepRequests(ctx context.Context, ) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification + + // SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk + // accepted notifications. These are sent by the server after the selected + // deposits are accepted by confirmation risk tracking. + SubscribeStaticLoopInRiskAccepted( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + + // SubscribeStaticLoopInRiskRejected subscribes to static loop in risk + // rejected notifications. These are sent by the server if it aborts the + // confirmation risk wait before payment. + SubscribeStaticLoopInRiskRejected( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification } From 3650ae9e4e73ed48db74a71de7ee860ed7102c05 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:57:01 +0200 Subject: [PATCH 13/35] loopdb: persist static loop-in risk decisions Add schema, sqlc queries, store fields, and SqlStore support for recording server confirmation-risk decisions with static loop-in swaps. Store the decision timestamp so payment-deadline recovery can reconstruct elapsed time after restart. --- ...00021_static_loopin_risk_decision.down.sql | 3 + .../000021_static_loopin_risk_decision.up.sql | 9 + loopdb/sqlc/models.go | 26 +-- loopdb/sqlc/querier.go | 1 + loopdb/sqlc/queries/static_address_loopin.sql | 20 ++- loopdb/sqlc/static_address_loopin.sql.go | 160 +++++++++++------- staticaddr/loopin/loopin.go | 26 +++ staticaddr/loopin/sql_store.go | 52 ++++++ staticaddr/loopin/sql_store_test.go | 77 +++++++++ 9 files changed, 296 insertions(+), 78 deletions(-) create mode 100644 loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql create mode 100644 loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql diff --git a/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql new file mode 100644 index 000000000..3a7313c18 --- /dev/null +++ b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.down.sql @@ -0,0 +1,3 @@ +-- Drop confirmation-risk decision fields from static address loop-ins. +ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision; +ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision_time; diff --git a/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql new file mode 100644 index 000000000..722fc57a3 --- /dev/null +++ b/loopdb/sqlc/migrations/000021_static_loopin_risk_decision.up.sql @@ -0,0 +1,9 @@ +-- confirmation_risk_decision records the server's confirmation-risk decision +-- for a static address loop-in. The empty string means no decision has been +-- received yet. +ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT ''; + +-- confirmation_risk_decision_time records when loopd received and persisted +-- the server's decision, so payment deadlines can be reconstructed after +-- restart. +ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP; diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 34d8a2778..78a75d042 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -137,18 +137,20 @@ type StaticAddress struct { } type StaticAddressSwap struct { - ID int32 - SwapHash []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool + ID int32 + SwapHash []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime } type StaticAddressSwapUpdate struct { diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index 2d8dc1379..ba3c35eb9 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -67,6 +67,7 @@ type Querier interface { MapDepositToSwap(ctx context.Context, arg MapDepositToSwapParams) error OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error + RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index 7ee326a2d..b4fca5d45 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -33,6 +33,22 @@ SET WHERE swap_hash = $1; +-- name: RecordStaticAddressRiskDecision :exec +UPDATE static_address_swaps +SET + confirmation_risk_decision = $2, + confirmation_risk_decision_time = CASE + WHEN confirmation_risk_decision = $2 THEN + COALESCE(confirmation_risk_decision_time, $3) + ELSE $3 + END +WHERE + swap_hash = $1 + AND NOT ( + confirmation_risk_decision = 'rejected' + AND $2 = 'accepted' + ); + -- name: InsertStaticAddressMetaUpdate :exec INSERT INTO static_address_swap_updates ( swap_hash, @@ -147,7 +163,3 @@ WHERE d.swap_hash = $1; - - - - diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 87949cb3e..319340168 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -153,7 +153,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([] const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index FROM swaps @@ -166,36 +166,38 @@ WHERE ` type GetStaticAddressLoopInSwapRow struct { - ID int32 - SwapHash []byte - Preimage []byte - InitiationTime time.Time - AmountRequested int64 - CltvExpiry int32 - MaxMinerFee int64 - MaxSwapFee int64 - InitiationHeight int32 - ProtocolVersion int32 - Label string - ID_2 int32 - SwapHash_2 []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool - SwapHash_3 []byte - SenderScriptPubkey []byte - ReceiverScriptPubkey []byte - SenderInternalPubkey []byte - ReceiverInternalPubkey []byte - ClientKeyFamily int32 - ClientKeyIndex int32 + ID int32 + SwapHash []byte + Preimage []byte + InitiationTime time.Time + AmountRequested int64 + CltvExpiry int32 + MaxMinerFee int64 + MaxSwapFee int64 + InitiationHeight int32 + ProtocolVersion int32 + Label string + ID_2 int32 + SwapHash_2 []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime + SwapHash_3 []byte + SenderScriptPubkey []byte + ReceiverScriptPubkey []byte + SenderInternalPubkey []byte + ReceiverInternalPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int32 } func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) { @@ -225,6 +227,8 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.HtlcTimeoutSweepAddress, &i.SelectedAmount, &i.Fast, + &i.ConfirmationRiskDecision, + &i.ConfirmationRiskDecisionTime, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -239,7 +243,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index FROM swaps @@ -263,36 +267,38 @@ ORDER BY ` type GetStaticAddressLoopInSwapsByStatesRow struct { - ID int32 - SwapHash []byte - Preimage []byte - InitiationTime time.Time - AmountRequested int64 - CltvExpiry int32 - MaxMinerFee int64 - MaxSwapFee int64 - InitiationHeight int32 - ProtocolVersion int32 - Label string - ID_2 int32 - SwapHash_2 []byte - SwapInvoice string - LastHop []byte - PaymentTimeoutSeconds int32 - QuotedSwapFeeSatoshis int64 - DepositOutpoints string - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString - HtlcTimeoutSweepAddress string - SelectedAmount int64 - Fast bool - SwapHash_3 []byte - SenderScriptPubkey []byte - ReceiverScriptPubkey []byte - SenderInternalPubkey []byte - ReceiverInternalPubkey []byte - ClientKeyFamily int32 - ClientKeyIndex int32 + ID int32 + SwapHash []byte + Preimage []byte + InitiationTime time.Time + AmountRequested int64 + CltvExpiry int32 + MaxMinerFee int64 + MaxSwapFee int64 + InitiationHeight int32 + ProtocolVersion int32 + Label string + ID_2 int32 + SwapHash_2 []byte + SwapInvoice string + LastHop []byte + PaymentTimeoutSeconds int32 + QuotedSwapFeeSatoshis int64 + DepositOutpoints string + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + HtlcTimeoutSweepAddress string + SelectedAmount int64 + Fast bool + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime + SwapHash_3 []byte + SenderScriptPubkey []byte + ReceiverScriptPubkey []byte + SenderInternalPubkey []byte + ReceiverInternalPubkey []byte + ClientKeyFamily int32 + ClientKeyIndex int32 } func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) { @@ -328,6 +334,8 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.HtlcTimeoutSweepAddress, &i.SelectedAmount, &i.Fast, + &i.ConfirmationRiskDecision, + &i.ConfirmationRiskDecisionTime, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -482,6 +490,34 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe return err } +const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec +UPDATE static_address_swaps +SET + confirmation_risk_decision = $2, + confirmation_risk_decision_time = CASE + WHEN confirmation_risk_decision = $2 THEN + COALESCE(confirmation_risk_decision_time, $3) + ELSE $3 + END +WHERE + swap_hash = $1 + AND NOT ( + confirmation_risk_decision = 'rejected' + AND $2 = 'accepted' + ) +` + +type RecordStaticAddressRiskDecisionParams struct { + SwapHash []byte + ConfirmationRiskDecision string + ConfirmationRiskDecisionTime sql.NullTime +} + +func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error { + _, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime) + return err +} + const swapHashForDepositID = `-- name: SwapHashForDepositID :one SELECT swap_hash diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 9b0fc03bd..7fcc3ff9a 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -31,6 +31,23 @@ import ( "github.com/lightningnetwork/lnd/zpay32" ) +// ConfirmationRiskDecision records the server's decision on whether it accepts +// waiting for low-confirmation deposits before paying a static loop-in invoice. +type ConfirmationRiskDecision string + +const ( + // ConfirmationRiskDecisionNone means no risk decision has been received. + ConfirmationRiskDecisionNone ConfirmationRiskDecision = "" + + // ConfirmationRiskDecisionAccepted means the server accepted waiting for + // deposit confirmations and the payment deadline has started. + ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted" + + // ConfirmationRiskDecisionRejected means the server stopped waiting for + // deposit confirmations before paying the invoice. + ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected" +) + // StaticAddressLoopIn represents the in-memory loop-in information. type StaticAddressLoopIn struct { // SwapHash is the hashed preimage of the swap invoice. It represents @@ -107,6 +124,15 @@ type StaticAddressLoopIn struct { // LastUpdateTime is the timestamp of the latest persisted state update. LastUpdateTime time.Time + // ConfirmationRiskDecision records the server's persisted decision on + // low-confirmation deposit risk. + ConfirmationRiskDecision ConfirmationRiskDecision + + // ConfirmationRiskDecisionTime is when loopd persisted the server risk + // decision. It is used to reconstruct payment-deadline timeouts after + // restart. + ConfirmationRiskDecisionTime time.Time + // state is the current state of the swap. state fsm.StateType diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index cbaf78fa6..8b36dbe4a 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -27,6 +27,9 @@ var ( // ErrInvalidOutpoint is returned when an outpoint contains the outpoint // separator. ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator") + + // ErrLoopInNotFound is returned when a loop-in swap is not stored. + ErrLoopInNotFound = errors.New("static address loop-in not found") ) // Querier is the interface that contains all the queries generated by sqlc for @@ -51,6 +54,11 @@ type Querier interface { UpdateStaticAddressLoopIn(ctx context.Context, arg sqlc.UpdateStaticAddressLoopInParams) error + // RecordStaticAddressRiskDecision stores the server's confirmation-risk + // decision for a loop-in swap. + RecordStaticAddressRiskDecision(ctx context.Context, + arg sqlc.RecordStaticAddressRiskDecisionParams) error + // GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash. GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error) @@ -361,6 +369,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, ) } +// RecordStaticAddressRiskDecision stores the server's confirmation-risk +// decision for a static address loop-in. The timestamp is written by the store +// so recovery can reconstruct the remaining payment deadline from one durable +// clock source. +func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context, + swapHash lntypes.Hash, decision ConfirmationRiskDecision) error { + + if decision != ConfirmationRiskDecisionAccepted && + decision != ConfirmationRiskDecisionRejected { + + return errors.New("unknown confirmation risk decision") + } + + params := sqlc.RecordStaticAddressRiskDecisionParams{ + SwapHash: swapHash[:], + ConfirmationRiskDecision: string(decision), + ConfirmationRiskDecisionTime: sql.NullTime{ + Time: s.clock.Now(), + Valid: true, + }, + } + + return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + func(q Querier) error { + stored, err := q.IsStored(ctx, swapHash[:]) + if err != nil { + return err + } + if !stored { + return ErrLoopInNotFound + } + + return q.RecordStaticAddressRiskDecision(ctx, params) + }, + ) +} + func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context, updateAmounts map[lntypes.Hash]btcutil.Amount) error { @@ -584,6 +629,9 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, DepositOutpoints: depositOutpoints, SelectedAmount: btcutil.Amount(swap.SelectedAmount), Fast: swap.Fast, + ConfirmationRiskDecision: ConfirmationRiskDecision( + swap.ConfirmationRiskDecision, + ), HtlcTxFeeRate: chainfee.SatPerKWeight( swap.HtlcTxFeeRateSatKw, ), @@ -591,6 +639,10 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, Deposits: depositList, } + if swap.ConfirmationRiskDecisionTime.Valid { + loopIn.ConfirmationRiskDecisionTime = + swap.ConfirmationRiskDecisionTime.Time + } if len(updates) > 0 { lastUpdate := updates[len(updates)-1] diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index fd4534b78..ffea0f065 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -349,6 +349,83 @@ func TestCreateLoopIn(t *testing.T) { require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()}, swap.DepositOutpoints) require.Equal(t, SignHtlcTx, swap.GetState()) + require.Equal( + t, ConfirmationRiskDecisionNone, + swap.ConfirmationRiskDecision, + ) + + decisionTime := time.Unix(123, 0).UTC() + testClock.SetTime(decisionTime) + err = swapStore.RecordStaticAddressRiskDecision( + ctx, swapHashPending, ConfirmationRiskDecisionAccepted, + ) + require.NoError(t, err) + + swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) + require.NoError(t, err) + require.Equal( + t, ConfirmationRiskDecisionAccepted, + swap.ConfirmationRiskDecision, + ) + require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime)) + + // Replaying the same decision must retain its original deadline anchor. + laterDecisionTime := decisionTime.Add(time.Hour) + testClock.SetTime(laterDecisionTime) + err = swapStore.RecordStaticAddressRiskDecision( + ctx, swapHashPending, ConfirmationRiskDecisionAccepted, + ) + require.NoError(t, err) + + swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) + require.NoError(t, err) + require.Equal( + t, ConfirmationRiskDecisionAccepted, + swap.ConfirmationRiskDecision, + ) + require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime)) + + // A different decision is a new event and receives a new timestamp. + rejectedDecisionTime := laterDecisionTime.Add(time.Hour) + testClock.SetTime(rejectedDecisionTime) + err = swapStore.RecordStaticAddressRiskDecision( + ctx, swapHashPending, ConfirmationRiskDecisionRejected, + ) + require.NoError(t, err) + + swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) + require.NoError(t, err) + require.Equal( + t, ConfirmationRiskDecisionRejected, + swap.ConfirmationRiskDecision, + ) + require.True(t, swap.ConfirmationRiskDecisionTime.Equal( + rejectedDecisionTime, + )) + + // Rejected is terminal: a racing synthetic acceptance must not replace + // the server's rejection or move its deadline anchor. + testClock.SetTime(rejectedDecisionTime.Add(time.Hour)) + err = swapStore.RecordStaticAddressRiskDecision( + ctx, swapHashPending, ConfirmationRiskDecisionAccepted, + ) + require.NoError(t, err) + + swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) + require.NoError(t, err) + require.Equal( + t, ConfirmationRiskDecisionRejected, + swap.ConfirmationRiskDecision, + ) + require.True(t, swap.ConfirmationRiskDecisionTime.Equal( + rejectedDecisionTime, + )) + + err = swapStore.RecordStaticAddressRiskDecision( + ctx, lntypes.Hash{0x9, 0x9, 0x9}, + ConfirmationRiskDecisionRejected, + ) + require.ErrorIs(t, err, ErrLoopInNotFound) require.Len(t, swap.Deposits, 2) From 819d3b7067b5fc49eeaf22ecb5c38cc6fb596d3b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:57:15 +0200 Subject: [PATCH 14/35] notifications: persist static loop-in risk decisions Persist static loop-in confirmation-risk decisions before fanout when a persistence callback is configured. Keep unpersisted decisions cached for replay so notification delivery is not lost if the swap row is not available yet. --- notifications/manager.go | 292 ++++++++++++++++++++-------------- notifications/manager_test.go | 184 ++++++++++++++++++++- 2 files changed, 349 insertions(+), 127 deletions(-) diff --git a/notifications/manager.go b/notifications/manager.go index ed408195d..eb47c1ed5 100644 --- a/notifications/manager.go +++ b/notifications/manager.go @@ -88,6 +88,13 @@ type Config struct { // MaxQueuedNotifications is the maximum number of notifications that // can wait in each subscriber's delivery queue. MaxQueuedNotifications int + + // PersistStaticLoopInRiskDecision durably records static loop-in + // confirmation-risk decisions. If this fails, the notification is still + // cached and forwarded so a later subscriber can process it after the swap + // row exists. + PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash, + bool) error } // Manager is a manager for notifications that the swap server sends to the @@ -99,13 +106,26 @@ type Manager struct { hasL402 bool + // subscribers holds active notification subscribers by notification + // type. It is guarded by the Manager mutex. subscribers map[NotificationType][]subscriber + // staticLoopInRiskAccepted caches accepted risk decisions by swap hash + // so a later matching subscriber can receive a previously delivered + // server decision. staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc. ServerStaticLoopInRiskAcceptedNotification + // staticLoopInRiskRejected caches rejected risk decisions by swap hash + // so a later matching subscriber can receive a previously delivered + // server decision. staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc. ServerStaticLoopInRiskRejectedNotification + + // staticLoopInRiskPersisted records whether the cached risk decision for + // a swap hash was durably persisted. Unpersisted decisions remain cached + // after subscriber cancellation so they can be replayed. + staticLoopInRiskPersisted map[lntypes.Hash]bool } // NewManager creates a new notification manager. @@ -129,14 +149,15 @@ func NewManager(cfg *Config) *Manager { map[lntypes.Hash]*swapserverrpc. ServerStaticLoopInRiskRejectedNotification, ), + staticLoopInRiskPersisted: make(map[lntypes.Hash]bool), } } type subscriber struct { subCtx context.Context recvChan any - enqueue func(any) swapHash *lntypes.Hash + enqueue func(any) } // newNotificationQueue creates a per-subscriber FIFO delivery function. @@ -245,6 +266,19 @@ func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) { } } +// dropNotification sends a best-effort notification to a subscriber. +func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T, + description string) { + + select { + case recvChan <- ntfn: + case <-sub.subCtx.Done(): + default: + log.Debugf("Dropping %s notification for slow subscriber", + description) + } +} + // SubscribeReservations subscribes to the reservation notifications. func (m *Manager) SubscribeReservations(ctx context.Context, ) <-chan *swapserverrpc.ServerReservationNotification { @@ -294,16 +328,11 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context, return notifChan } -// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted -// notifications. -func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, - swapHash lntypes.Hash, -) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { - - notifChan := make( - chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1, - ) +func subscribeStaticLoopInRiskDecision[T any](m *Manager, ctx context.Context, + swapHash lntypes.Hash, notifType NotificationType, + notifications map[lntypes.Hash]T) <-chan T { + notifChan := make(chan T, 1) sub := subscriber{ subCtx: ctx, recvChan: notifChan, @@ -311,19 +340,25 @@ func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, } m.Lock() - m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append( - m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub, - ) - if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok { + m.subscribers[notifType] = append(m.subscribers[notifType], sub) + if ntfn, ok := notifications[swapHash]; ok { notifChan <- ntfn - delete(m.staticLoopInRiskAccepted, swapHash) + if m.staticLoopInRiskPersisted[swapHash] { + delete(notifications, swapHash) + delete(m.staticLoopInRiskPersisted, swapHash) + } } m.Unlock() context.AfterFunc(ctx, func() { - m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub) + m.removeSubscriber(notifType, sub) m.Lock() - delete(m.staticLoopInRiskAccepted, swapHash) + if _, ok := notifications[swapHash]; ok && + m.staticLoopInRiskPersisted[swapHash] { + + delete(notifications, swapHash) + delete(m.staticLoopInRiskPersisted, swapHash) + } m.Unlock() close(notifChan) }) @@ -331,41 +366,28 @@ func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, return notifChan } -// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected +// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted // notifications. -func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context, +func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context, swapHash lntypes.Hash, -) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { - notifChan := make( - chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1, + return subscribeStaticLoopInRiskDecision( + m, ctx, swapHash, NotificationTypeStaticLoopInRiskAccepted, + m.staticLoopInRiskAccepted, ) +} - sub := subscriber{ - subCtx: ctx, - recvChan: notifChan, - swapHash: &swapHash, - } +// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected +// notifications. +func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context, + swapHash lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { - m.Lock() - m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append( - m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub, + return subscribeStaticLoopInRiskDecision( + m, ctx, swapHash, NotificationTypeStaticLoopInRiskRejected, + m.staticLoopInRiskRejected, ) - if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok { - notifChan <- ntfn - delete(m.staticLoopInRiskRejected, swapHash) - } - m.Unlock() - - context.AfterFunc(ctx, func() { - m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub) - m.Lock() - delete(m.staticLoopInRiskRejected, swapHash) - m.Unlock() - close(notifChan) - }) - - return notifChan } // SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications. @@ -525,7 +547,7 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error { notification, err := notifStream.Recv() if err == nil && notification != nil { log.Tracef("Received notification: %v", notification) - m.handleNotification(notification) + m.handleNotification(ctx, notification) continue } @@ -535,9 +557,73 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error { } } +// staticLoopInRiskDecisionName returns the log label for a risk decision. +func staticLoopInRiskDecisionName(accepted bool) string { + if accepted { + return "accepted" + } + + return "rejected" +} + +// handleStaticLoopInRiskDecision persists, caches, and forwards a risk +// decision notification to the matching subscriber. +func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context, + swapHashBytes []byte, accepted bool, notifType NotificationType, + cacheDecision func(lntypes.Hash, bool), + notifySubscriber func(subscriber)) { + + decision := staticLoopInRiskDecisionName(accepted) + persisted := m.cfg.PersistStaticLoopInRiskDecision == nil + + var ( + swapHash lntypes.Hash + hasSwapHash bool + ) + if swapHashBytes != nil { + hash, err := lntypes.MakeHash(swapHashBytes) + if err != nil { + log.Warnf("Received invalid static loop in risk "+ + "%s notification: %v", decision, err) + } else { + swapHash = hash + hasSwapHash = true + } + } + + if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil { + err := m.cfg.PersistStaticLoopInRiskDecision( + ctx, swapHash, accepted, + ) + if err != nil { + log.Errorf("Unable to persist static loop in risk "+ + "%s notification: %v", decision, err) + } else { + persisted = true + } + } + + m.Lock() + defer m.Unlock() + + if hasSwapHash { + cacheDecision(swapHash, persisted) + } + + for _, sub := range m.subscribers[notifType] { + if !hasSwapHash || sub.swapHash == nil || + *sub.swapHash != swapHash { + + continue + } + + notifySubscriber(sub) + } +} + // handleNotification handles an incoming notification from the server, // forwarding it to the appropriate subscribers. -func (m *Manager) handleNotification(ntfn *swapserverrpc. +func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc. SubscribeNotificationsResponse) { switch ntfn.Notification.(type) { @@ -577,89 +663,57 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc. // We'll forward the static loop in risk accepted notification to the // subscriber for the matching swap. riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted() - m.Lock() - defer m.Unlock() - - var ( - swapHash lntypes.Hash - hasSwapHash bool - ) + var swapHashBytes []byte if riskAcceptedNtfn != nil { - hash, err := lntypes.MakeHash(riskAcceptedNtfn.SwapHash) - if err != nil { - log.Warnf("Received invalid static loop in risk "+ - "accepted notification: %v", err) - } else { - swapHash = hash - hasSwapHash = true - m.staticLoopInRiskAccepted[hash] = - riskAcceptedNtfn - delete(m.staticLoopInRiskRejected, hash) - } + swapHashBytes = riskAcceptedNtfn.SwapHash } - for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskAccepted] { // nolint: lll - if !hasSwapHash || sub.swapHash == nil || - *sub.swapHash != swapHash { - - continue - } - - recvChan := sub.recvChan.(chan *swapserverrpc. - ServerStaticLoopInRiskAcceptedNotification) - - select { - case recvChan <- riskAcceptedNtfn: - case <-sub.subCtx.Done(): - default: - log.Debugf("Dropping static loop in risk " + - "accepted notification for slow subscriber") - } - } + m.handleStaticLoopInRiskDecision( + ctx, swapHashBytes, true, + NotificationTypeStaticLoopInRiskAccepted, + func(swapHash lntypes.Hash, persisted bool) { + m.staticLoopInRiskAccepted[swapHash] = + riskAcceptedNtfn + m.staticLoopInRiskPersisted[swapHash] = persisted + delete(m.staticLoopInRiskRejected, swapHash) + }, + func(sub subscriber) { + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification) + dropNotification( + sub, recvChan, riskAcceptedNtfn, + "static loop in risk accepted", + ) + }, + ) case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll // We'll forward the static loop in risk rejected notification to the // subscriber for the matching swap. riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected() - m.Lock() - defer m.Unlock() - - var ( - swapHash lntypes.Hash - hasSwapHash bool - ) + var swapHashBytes []byte if riskRejectedNtfn != nil { - hash, err := lntypes.MakeHash(riskRejectedNtfn.SwapHash) - if err != nil { - log.Warnf("Received invalid static loop in risk "+ - "rejected notification: %v", err) - } else { - swapHash = hash - hasSwapHash = true - m.staticLoopInRiskRejected[hash] = - riskRejectedNtfn - delete(m.staticLoopInRiskAccepted, hash) - } + swapHashBytes = riskRejectedNtfn.SwapHash } - for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskRejected] { // nolint: lll - if !hasSwapHash || sub.swapHash == nil || - *sub.swapHash != swapHash { - - continue - } - - recvChan := sub.recvChan.(chan *swapserverrpc. - ServerStaticLoopInRiskRejectedNotification) - - select { - case recvChan <- riskRejectedNtfn: - case <-sub.subCtx.Done(): - default: - log.Debugf("Dropping static loop in risk " + - "rejected notification for slow subscriber") - } - } + m.handleStaticLoopInRiskDecision( + ctx, swapHashBytes, false, + NotificationTypeStaticLoopInRiskRejected, + func(swapHash lntypes.Hash, persisted bool) { + m.staticLoopInRiskRejected[swapHash] = + riskRejectedNtfn + m.staticLoopInRiskPersisted[swapHash] = persisted + delete(m.staticLoopInRiskAccepted, swapHash) + }, + func(sub subscriber) { + recvChan := sub.recvChan.(chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification) + dropNotification( + sub, recvChan, riskRejectedNtfn, + "static loop in risk rejected", + ) + }, + ) case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll // We'll forward the unfinished swap notification to all diff --git a/notifications/manager_test.go b/notifications/manager_test.go index b165c494b..59ecab78f 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -220,6 +220,7 @@ func staticLoopInSweepNotification( } } +// staticLoopInRiskAcceptedNotification builds a risk accepted notification. func staticLoopInRiskAcceptedNotification( swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse { @@ -271,7 +272,7 @@ func assertStaticLoopInRiskNotificationSwapScoped[ subChanA := subscribe(mgr, subCtx, swapHashA) subChanB := subscribe(mgr, subCtx, swapHashB) - mgr.handleNotification(notification(swapHashA)) + mgr.handleNotification(t.Context(), notification(swapHashA)) select { case received := <-subChanA: @@ -290,7 +291,7 @@ func assertStaticLoopInRiskNotificationSwapScoped[ default: } - mgr.handleNotification(notification(swapHashB)) + mgr.handleNotification(t.Context(), notification(swapHashB)) select { case received := <-subChanB: @@ -320,7 +321,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) { fastChan := mgr.SubscribeReservations(fastCtx) firstNotif := getTestNotification(testReservationId) - mgr.handleNotification(firstNotif) + mgr.handleNotification(t.Context(), firstNotif) received := <-fastChan require.Equal(t, testReservationId, received.ReservationId) @@ -328,7 +329,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) { secondNotif := getTestNotification(testReservationId2) done := make(chan struct{}) go func() { - mgr.handleNotification(secondNotif) + mgr.handleNotification(t.Context(), secondNotif) close(done) }() @@ -427,7 +428,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) { subChan := mgr.SubscribeUnfinishedSwaps(subCtx) swapHashA := lntypes.Hash{0x21, 0x22} - mgr.handleNotification(unfinishedSwapNotification(swapHashA)) + mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashA)) require.Eventually(t, func() bool { return len(subChan) == 1 @@ -436,7 +437,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) { swapHashB := lntypes.Hash{0x23, 0x24} done := make(chan struct{}) go func() { - mgr.handleNotification(unfinishedSwapNotification(swapHashB)) + mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashB)) close(done) }() @@ -508,11 +509,11 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T, subChan := subscribe(mgr, subCtx) - mgr.handleNotification(notification(swapHashA)) + mgr.handleNotification(t.Context(), notification(swapHashA)) done := make(chan struct{}) go func() { - mgr.handleNotification(notification(swapHashB)) + mgr.handleNotification(t.Context(), notification(swapHashB)) close(done) }() @@ -557,6 +558,7 @@ func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) { subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ @@ -577,6 +579,169 @@ func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) { } } +// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are +// handed to the durable callback before they are treated as delivered. +func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) { + t.Parallel() + + type persistedDecision struct { + swapHash lntypes.Hash + accepted bool + } + + persisted := make(chan persistedDecision, 2) + mgr := NewManager(&Config{ + PersistStaticLoopInRiskDecision: func(_ context.Context, + swapHash lntypes.Hash, accepted bool) error { + + persisted <- persistedDecision{ + swapHash: swapHash, + accepted: accepted, + } + + return nil + }, + }) + + acceptedHash := lntypes.Hash{0x16, 0x17} + rejectedHash := lntypes.Hash{0x18, 0x19} + + mgr.handleNotification( + t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash), + ) + mgr.handleNotification( + t.Context(), staticLoopInRiskRejectedNotification(rejectedHash), + ) + + select { + case decision := <-persisted: + require.Equal(t, acceptedHash, decision.swapHash) + require.True(t, decision.accepted) + + case <-time.After(time.Second): + t.Fatal("accepted risk decision was not persisted") + } + + select { + case decision := <-persisted: + require.Equal(t, rejectedHash, decision.swapHash) + require.False(t, decision.accepted) + + case <-time.After(time.Second): + t.Fatal("rejected risk decision was not persisted") + } +} + +// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an +// early risk notification is still cached if the swap row does not exist yet. +func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) { + t.Parallel() + + swapHash := lntypes.Hash{0x1a, 0x1b} + mgr := NewManager(&Config{ + PersistStaticLoopInRiskDecision: func(_ context.Context, + _ lntypes.Hash, _ bool) error { + + return errors.New("swap not stored yet") + }, + }) + + mgr.handleNotification( + t.Context(), staticLoopInRiskAcceptedNotification(swapHash), + ) + + subCtx, subCancel := context.WithCancel(t.Context()) + defer subCancel() + + subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash) + + select { + case received := <-subChan: + require.Equal(t, swapHash[:], received.SwapHash) + + case <-time.After(time.Second): + t.Fatal("did not replay risk notification after persist failure") + } +} + +// TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel verifies that +// a non-persisted risk decision remains replayable if the subscriber is canceled +// before the FSM has a chance to process it. +func TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel( + t *testing.T) { + + t.Parallel() + + assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel( + t, + (*Manager).SubscribeStaticLoopInRiskAccepted, + staticLoopInRiskAcceptedNotification, + ) + assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel( + t, + (*Manager).SubscribeStaticLoopInRiskRejected, + staticLoopInRiskRejectedNotification, + ) +} + +func assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel[ + T staticLoopInRiskNotification](t *testing.T, + subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T, + notification func(lntypes.Hash) *swapserverrpc. + SubscribeNotificationsResponse) { + + t.Helper() + + swapHash := lntypes.Hash{0x2a, 0x2b} + mgr := NewManager(&Config{ + PersistStaticLoopInRiskDecision: func(_ context.Context, + _ lntypes.Hash, _ bool) error { + + return errors.New("swap not stored yet") + }, + }) + + subCtx, subCancel := context.WithCancel(t.Context()) + subChan := subscribe(mgr, subCtx, swapHash) + + mgr.handleNotification(t.Context(), notification(swapHash)) + + require.Eventually(t, func() bool { + return len(subChan) == 1 + }, time.Second, 10*time.Millisecond) + + subCancel() + + select { + case <-subChan: + + case <-time.After(time.Second): + t.Fatal("risk decision notification was not delivered before " + + "cancel") + } + + select { + case _, ok := <-subChan: + require.False(t, ok) + + case <-time.After(time.Second): + t.Fatal("risk decision subscription did not close after cancel") + } + + replayCtx, replayCancel := context.WithCancel(t.Context()) + defer replayCancel() + + replayChan := subscribe(mgr, replayCtx, swapHash) + select { + case received := <-replayChan: + require.Equal(t, swapHash[:], received.GetSwapHash()) + + case <-time.After(time.Second): + t.Fatal("cached risk decision was lost after subscriber " + + "cancellation") + } +} + // TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a // notification for one swap does not occupy another swap's subscriber channel. func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) { @@ -603,6 +768,7 @@ func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) { swapHash := lntypes.Hash{0x06, 0x07} mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ @@ -643,6 +809,7 @@ func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) { subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskRejected{ @@ -689,6 +856,7 @@ func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) { swapHash := lntypes.Hash{0x0a, 0x0b} mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskRejected{ From f5ffb3e0db174ee43b1093bc74ed5671c05b9bbc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:57:34 +0200 Subject: [PATCH 15/35] loopd: wire static risk decision persistence Create the static loop-in SQL store before the notification manager and pass a persistence callback so server confirmation-risk decisions are durably recorded before fanout. --- loopd/daemon.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/loopd/daemon.go b/loopd/daemon.go index dd29d1a5d..68b761414 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -555,10 +555,30 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } } + // Static address loop-in store setup is needed by the notification + // manager so confirmation-risk decisions are durable before fan-out. + staticAddressLoopInStore := loopin.NewSqlStore( + loopdb.NewTypedStore[loopin.Querier](baseDb), + clock.NewDefaultClock(), d.lnd.ChainParams, + ) + // Start the notification manager. notificationCfg := ¬ifications.Config{ Client: loop_swaprpc.NewSwapServerClient(swapClient.Conn), CurrentToken: swapClient.L402Store.CurrentToken, + PersistStaticLoopInRiskDecision: func(ctx context.Context, + swapHash lntypes.Hash, accepted bool) error { + + decision := loopin.ConfirmationRiskDecisionRejected + if accepted { + decision = loopin.ConfirmationRiskDecisionAccepted + } + + return staticAddressLoopInStore. + RecordStaticAddressRiskDecision( + ctx, swapHash, decision, + ) + }, } notificationManager := notifications.NewManager(notificationCfg) @@ -662,12 +682,6 @@ func (d *Daemon) initialize(withMacaroonService bool) error { } openChannelManager = openchannel.NewManager(openChannelCfg) - // Static address loop-in manager setup. - staticAddressLoopInStore := loopin.NewSqlStore( - loopdb.NewTypedStore[loopin.Querier](baseDb), - clock.NewDefaultClock(), d.lnd.ChainParams, - ) - // Run the deposit swap hash migration. err = loopin.MigrateDepositSwapHash( d.mainCtx, swapDb, depositStore, staticAddressLoopInStore, From a18fb9c4202877ee2b93736cc4d87b3ff7e9585e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:59:29 +0200 Subject: [PATCH 16/35] staticaddr/loopin: clean up deposits after unpaid htlc timeout Track whether the invoice was canceled for non-payment while monitoring the HTLC. If the HTLC never confirms before timeout, unlock the deposits; if it did confirm, transition them to the HTLC-timeout sweep state without issuing duplicate transitions. --- staticaddr/deposit/deposit.go | 6 ++ staticaddr/loopin/actions.go | 92 ++++++++++++++++++----- staticaddr/loopin/actions_test.go | 120 ++++++++++++++++++++++++++++-- 3 files changed, 192 insertions(+), 26 deletions(-) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index b784b5112..d63cc4b74 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -132,6 +132,12 @@ func (d *Deposit) isInStateNoLock(state fsm.StateType) bool { return d.state == state } +// IsInStateNoLock returns whether the deposit is in the given state without +// acquiring the deposit lock. +func (d *Deposit) IsInStateNoLock(state fsm.StateType) bool { + return d.isInStateNoLock(state) +} + // GetConfirmationHeight returns the deposit confirmation height. func (d *Deposit) GetConfirmationHeight() int64 { d.Lock() diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 6111469d2..5ceb6864f 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -900,6 +900,32 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } }() + depositsInState := func(state fsm.StateType) bool { + if len(f.loopIn.Deposits) == 0 { + return false + } + + for _, d := range f.loopIn.Deposits { + if d == nil { + return false + } + + d.Lock() + inState := d.IsInStateNoLock(state) + d.Unlock() + if !inState { + return false + } + } + + return true + } + + invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled + depositsLockedForHtlcTimeout := depositsInState( + deposit.SweepHtlcTimeout, + ) + startPaymentDeadline := func(reason string) { if deadlineStarted || invoice.State == invoices.ContractCanceled { return @@ -913,6 +939,30 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, deadlineStarted = true } + transitionDepositsToHtlcTimeout := func(reason string) { + if depositsLockedForHtlcTimeout || + depositsInState(deposit.SweepHtlcTimeout) { + + depositsLockedForHtlcTimeout = true + return + } + + err = f.cfg.DepositManager.TransitionDeposits( + ctx, f.loopIn.Deposits, + deposit.OnSweepingHtlcTimeout, + deposit.SweepHtlcTimeout, + ) + if err != nil { + f.Errorf("unable to transition deposits to the htlc "+ + "timeout sweeping state after %s: %v", + reason, err) + + return + } + + depositsLockedForHtlcTimeout = true + } + if invoice.State == invoices.ContractCanceled { // If the invoice was canceled previously we end our // subscription to invoice updates. @@ -929,6 +979,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Reuse the same helper as InitHtlcAction so timeout cleanup // follows the same detached-context path as early-init cleanup. f.cancelSwapInvoice() + invoice.State = invoices.ContractCanceled + invoiceCanceledForNonPayment = true } htlcConfirmed := false @@ -938,6 +990,11 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.Infof("htlc tx confirmed") htlcConfirmed = true + if invoiceCanceledForNonPayment { + transitionDepositsToHtlcTimeout( + "htlc confirmation after invoice cancellation", + ) + } case err = <-htlcErrConfChan: if ctx.Err() != nil { @@ -985,11 +1042,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, case <-deadlineChan: deadlineChan = nil - // If the server didn't pay the invoice on time, we - // cancel the invoice and keep monitoring the htlc tx - // confirmation. We also need to unlock the deposits to - // re-enable them for loop-ins and withdrawals. + // If the server didn't pay the invoice on time, we cancel + // it and keep monitoring the htlc tx. Confirmed HTLC + // deposits remain locked for timeout sweeping. cancelInvoice() + if htlcConfirmed { + transitionDepositsToHtlcTimeout("payment deadline") + continue + } err = f.unlockDeposits(ctx) if err != nil { @@ -1095,25 +1155,21 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // If the htlc hasn't confirmed but the timeout // path opened up, and we didn't receive the // swap payment, we consider the swap attempt to - // be failed. We cancelled the invoice, but - // don't need to unlock the deposits because - // that happened when the payment deadline was - // reached. + // be failed. Now that the HTLC can no longer + // confirm, the deposits can be made available + // again. + err = f.unlockDeposits(ctx) + if err != nil { + f.Errorf("unable to unlock deposits "+ + "after htlc timeout: %v", err) + } + return OnSwapTimedOut } // If the htlc has confirmed and the timeout path has // opened up we sweep the funds back to us. - err = f.cfg.DepositManager.TransitionDeposits( - ctx, f.loopIn.Deposits, - deposit.OnSweepingHtlcTimeout, - deposit.SweepHtlcTimeout, - ) - if err != nil { - log.Errorf("unable to transition "+ - "deposits to the htlc timeout "+ - "sweeping state: %v", err) - } + transitionDepositsToHtlcTimeout("htlc timeout") return OnSweepHtlcTimeout diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 2f8cdb724..8a16c628c 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -367,6 +367,97 @@ func TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown(t *testing.T) { } } +// TestMonitorInvoiceAndHtlcTxShutdownDoesNotUnlock verifies that daemon +// shutdown exits the monitor action without treating the swap as failed. +func TestMonitorInvoiceAndHtlcTxShutdownDoesNotUnlock(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + runCtx, stop := context.WithCancel(ctx) + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{1, 2, 4} + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + Deposits: []*deposit.Deposit{{ + Value: 200_000, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{ + transitionChan: make(chan depositTransition, 1), + } + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(runCtx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + stop() + + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.NoError(t, f.LastActionError) + + select { + case transition := <-depositMgr.transitionChan: + t.Fatalf("deposit transition on shutdown: %v", transition) + + default: + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled on shutdown: %v", hash) + + default: + } +} + // TestInitHtlcActionPreservesRouteHints asserts that static-address loop-in // propagates explicit route hints into the encoded swap invoice sent to the // server. @@ -574,10 +665,10 @@ func testValidateLoopInContract(_ int32, _ int32) error { return nil } -// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the -// payment timeout does not start until the server notifies us that confirmation -// risk was accepted. -func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { +// TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline verifies that the +// payment timeout starts on risk acceptance and keeps confirmed HTLC deposits +// locked for timeout sweeping. +func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() @@ -651,7 +742,19 @@ func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) }() - waitForMonitorSubscriptions(t, ctx, mockLnd) + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + var confRegistration *test.ConfRegistration + select { + case confRegistration = <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } + confRegistration.ConfChan <- nil select { case hash := <-mockLnd.FailInvoiceChannel: @@ -685,11 +788,12 @@ func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { require.Equal(t, []*deposit.Deposit{ loopIn.Deposits[0], }, transition.deposits) - require.Equal(t, fsm.OnError, transition.event) - require.Equal(t, deposit.Deposited, transition.state) + require.Equal(t, deposit.OnSweepingHtlcTimeout, transition.event) + require.Equal(t, deposit.SweepHtlcTimeout, transition.state) case <-ctx.Done(): - t.Fatalf("deposits were not unlocked: %v", ctx.Err()) + t.Fatalf("deposits were not locked for timeout sweeping: %v", + ctx.Err()) } cancel() From 14bb1d2e61c093270ca79d774c45260977d57521 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 14:00:00 +0200 Subject: [PATCH 17/35] staticaddr/loopin: recover risk-decision deadlines Record replayed server risk decisions through the loop-in store, recover accepted payment-deadline timers using the persisted decision time, and handle persisted rejections on restart. This lets recovered static loop-ins keep pending confirmation-risk state instead of restarting payment timing from scratch. --- loopd/swapclient_server_test.go | 8 + staticaddr/loopin/actions.go | 174 +++++++--- staticaddr/loopin/actions_test.go | 511 +++++++++++++++++++++++++++++- staticaddr/loopin/interface.go | 5 + staticaddr/loopin/manager_test.go | 7 + 5 files changed, 653 insertions(+), 52 deletions(-) diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 58c5dac97..f3fab0328 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -536,6 +536,14 @@ func (s *mockStaticAddressLoopInStore) IsStored(_ context.Context, return false, nil } +// RecordStaticAddressRiskDecision satisfies the static loop-in store interface. +func (s *mockStaticAddressLoopInStore) RecordStaticAddressRiskDecision( + _ context.Context, _ lntypes.Hash, + _ loopin.ConfirmationRiskDecision) error { + + return nil +} + // GetLoopInByHash returns the configured loop-in with the given hash. func (s *mockStaticAddressLoopInStore) GetLoopInByHash(_ context.Context, swapHash lntypes.Hash) (*loopin.StaticAddressLoopIn, error) { diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 5ceb6864f..47ffbbe77 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -926,12 +926,18 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, deposit.SweepHtlcTimeout, ) - startPaymentDeadline := func(reason string) { + startPaymentDeadline := func(reason string, startedAt time.Time) { if deadlineStarted || invoice.State == invoices.ContractCanceled { return } timeout := f.loopIn.PaymentTimeoutDuration() + if !startedAt.IsZero() { + timeout -= time.Since(startedAt) + if timeout < 0 { + timeout = 0 + } + } f.Infof("starting payment deadline after %s", reason) deadlineTimer = time.NewTimer(timeout) @@ -963,6 +969,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, depositsLockedForHtlcTimeout = true } + startLegacyFallback := func(reason string, currentHeight int32) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return + } + + if f.shouldStartLegacyConfirmationFallback(ctx, currentHeight) { + startPaymentDeadline(reason, time.Time{}) + } + } + if invoice.State == invoices.ContractCanceled { // If the invoice was canceled previously we end our // subscription to invoice updates. @@ -983,6 +999,108 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, invoiceCanceledForNonPayment = true } + riskDecisionTime := func(decision ConfirmationRiskDecision) time.Time { + now := time.Now() + if f.cfg.Store == nil { + return now + } + + storedLoopIn, err := f.cfg.Store.GetLoopInByHash( + ctx, f.loopIn.SwapHash, + ) + if err != nil { + f.Warnf("unable to reload persisted risk decision for "+ + "swap %v: %v", f.loopIn.SwapHash, err) + + return now + } + + if storedLoopIn == nil { + return now + } + + hasPersistedDecision := + storedLoopIn.ConfirmationRiskDecision == decision && + !storedLoopIn.ConfirmationRiskDecisionTime.IsZero() + + if !hasPersistedDecision { + err = f.cfg.Store.RecordStaticAddressRiskDecision( + ctx, f.loopIn.SwapHash, decision, + ) + if err != nil { + f.Warnf("unable to persist replayed risk "+ + "decision for swap %v: %v", + f.loopIn.SwapHash, err) + + return now + } + + storedLoopIn, err = f.cfg.Store.GetLoopInByHash( + ctx, f.loopIn.SwapHash, + ) + if err != nil { + f.Warnf("unable to reload persisted risk "+ + "decision for swap %v: %v", + f.loopIn.SwapHash, err) + + return now + } + if storedLoopIn == nil || + storedLoopIn.ConfirmationRiskDecision != decision || + storedLoopIn.ConfirmationRiskDecisionTime.IsZero() { + + return now + } + } + + f.loopIn.ConfirmationRiskDecision = + storedLoopIn.ConfirmationRiskDecision + f.loopIn.ConfirmationRiskDecisionTime = + storedLoopIn.ConfirmationRiskDecisionTime + + return storedLoopIn.ConfirmationRiskDecisionTime + } + + handleRiskRejected := func(reason string) fsm.EventType { + cancelInvoiceSubscription() + f.cancelSwapInvoice() + invoice.State = invoices.ContractCanceled + invoiceCanceledForNonPayment = true + decisionTime := riskDecisionTime( + ConfirmationRiskDecisionRejected, + ) + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionRejected + f.loopIn.ConfirmationRiskDecisionTime = decisionTime + riskAcceptedChan = nil + riskRejectedChan = nil + + return f.HandleError(fmt.Errorf( + "server rejected confirmation risk wait after %s", reason, + )) + } + + switch f.loopIn.ConfirmationRiskDecision { + case ConfirmationRiskDecisionAccepted: + startPaymentDeadline( + "recovered risk accepted notification", + f.loopIn.ConfirmationRiskDecisionTime, + ) + + case ConfirmationRiskDecisionRejected: + return handleRiskRejected("recovered risk rejection") + } + + info, err := f.cfg.LndClient.GetInfo(ctx) + if err != nil { + f.Warnf("unable to query current height for legacy confirmation "+ + "fallback: %v", err) + } else { + startLegacyFallback( + "legacy confirmation fallback", int32(info.BlockHeight), + ) + } + htlcConfirmed := false for { select { @@ -1070,7 +1188,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, continue } - startPaymentDeadline("risk accepted notification") + startedAt := riskDecisionTime( + ConfirmationRiskDecisionAccepted, + ) + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionAccepted + f.loopIn.ConfirmationRiskDecisionTime = startedAt + startPaymentDeadline( + "risk accepted notification", + f.loopIn.ConfirmationRiskDecisionTime, + ) case riskRejected, ok := <-riskRejectedChan: if !ok { @@ -1085,47 +1212,12 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, continue } - cancelInvoiceSubscription() - f.cancelSwapInvoice() - - return f.HandleError(errors.New( - "server rejected confirmation risk wait", - )) + return handleRiskRejected("risk rejection") case currentHeight := <-blockChan: - if !deadlineStarted && - invoice.State != invoices.ContractCanceled { - - err = f.refreshSelectedDeposits(ctx) - if err != nil { - f.Warnf("unable to refresh selected "+ - "deposits for legacy confirmation "+ - "fallback: %v", err) - } else { - depositConfirmationHeights := - selectedDepositConfirmationHeights( - f.loopIn, - ) - - if legacyMinConfsReached( - f.loopIn.DepositOutpoints, - depositConfirmationHeights, - currentHeight, - ) { - - // This fallback is a compatibility path for - // servers that do not send confirmation-risk - // notifications. Reaching legacy MinConfs is - // treated as synthetic risk acceptance, so the - // payment window starts here just as it would - // when a modern server sends an acceptance - // notification. - startPaymentDeadline( - "legacy confirmation fallback", - ) - } - } - } + startLegacyFallback( + "legacy confirmation fallback", currentHeight, + ) // If the htlc is confirmed but blockChan fires before // htlcConfChan, we would wrongfully assume that the diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 8a16c628c..030aed300 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -124,7 +124,7 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { SwapHash: swapHash, HtlcCltvExpiry: 2_000, InitiationHeight: uint32(mockLnd.Height), - InitiationTime: time.Now(), + InitiationTime: time.Now().Add(-time.Hour), ProtocolVersion: version.ProtocolVersion_V0, ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), @@ -806,9 +806,238 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { } } -// TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected verifies that a server-side -// confirmation risk rejection is terminal for the client monitor action. -func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { +// TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime verifies that live +// risk notifications use the durable receipt time, not the local channel +// receive time, when reconstructing the payment deadline. +func TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{4, 5, 7} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + Store: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: { + ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted, + ConfirmationRiskDecisionTime: time.Now().Add( + -time.Minute, + ), + }, + }, + }, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before risk acceptance: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxPersistsReplayedRiskAccepted verifies that a risk +// notification replayed after the swap row exists is written back to the store. +func TestMonitorInvoiceAndHtlcTxPersistsReplayedRiskAccepted(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{14}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + } + store := &recordingRiskStore{ + mockStore: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: {}, + }, + }, + decisions: make(chan ConfirmationRiskDecision, 1), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + Store: store, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case decision := <-store.decisions: + require.Equal(t, ConfirmationRiskDecisionAccepted, decision) + + case <-ctx.Done(): + t.Fatalf("risk decision was not persisted: %v", ctx.Err()) + } + + stored := store.loopIns[swapHash] + require.Equal(t, ConfirmationRiskDecisionAccepted, + stored.ConfirmationRiskDecision) + require.False(t, stored.ConfirmationRiskDecisionTime.IsZero()) + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxPersistsRiskRejected verifies that a server-side +// confirmation risk rejection is persisted and exits through the generic error +// path so the FSM unlocks deposits. +func TestMonitorInvoiceAndHtlcTxPersistsRiskRejected(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() @@ -827,7 +1056,7 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, - HtlcCltvExpiry: 2_000, + HtlcCltvExpiry: mockLnd.Height, InitiationHeight: uint32(mockLnd.Height), InitiationTime: time.Now(), ProtocolVersion: version.ProtocolVersion_V0, @@ -855,6 +1084,15 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { ), } + store := &recordingRiskStore{ + mockStore: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: {}, + }, + }, + decisions: make(chan ConfirmationRiskDecision, 1), + } + cfg := &Config{ AddressManager: &mockAddressManager{ params: &script.Parameters{ @@ -869,6 +1107,7 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, NotificationManager: notificationMgr, + Store: store, } f, err := NewFSM(ctx, loopIn, cfg, false) @@ -893,6 +1132,19 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { t.Fatalf("invoice was not canceled: %v", ctx.Err()) } + select { + case decision := <-store.decisions: + require.Equal(t, ConfirmationRiskDecisionRejected, decision) + + case <-ctx.Done(): + t.Fatalf("risk decision was not persisted: %v", ctx.Err()) + } + + stored := store.loopIns[swapHash] + require.Equal(t, ConfirmationRiskDecisionRejected, + stored.ConfirmationRiskDecision) + require.False(t, stored.ConfirmationRiskDecisionTime.IsZero()) + select { case event := <-resultChan: require.Equal(t, fsm.OnError, event) @@ -900,7 +1152,201 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { t, f.LastActionError, "server rejected confirmation risk wait", ) + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision verifies that a +// persisted risk acceptance restarts the payment deadline with elapsed time +// preserved after restart. +func TestMonitorInvoiceAndHtlcTxRecoversAcceptedRiskDecision(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 8} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted, + ConfirmationRiskDecisionTime: time.Now().Add(-time.Minute), + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{ + transitionChan: make(chan depositTransition, 1), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case transition := <-depositMgr.transitionChan: + require.Equal(t, fsm.OnError, transition.event) + require.Equal(t, deposit.Deposited, transition.state) + + case <-ctx.Done(): + t.Fatalf("deposits were not unlocked: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision verifies that a +// persisted risk rejection still cancels after restart and exits through the +// generic error path so the FSM unlocks deposits. +func TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: mockLnd.Height, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + ConfirmationRiskDecision: ConfirmationRiskDecisionRejected, + ConfirmationRiskDecisionTime: time.Now(), + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{ + transitionChan: make(chan depositTransition, 1), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, + "server rejected confirmation risk wait", + ) case <-time.After(time.Second): t.Fatal("monitor action did not exit") } @@ -1171,6 +1617,14 @@ func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) { require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after deposit "+ + "confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + select { case hash := <-mockLnd.FailInvoiceChannel: require.Equal(t, swapHash, hash) @@ -1217,7 +1671,7 @@ func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager( SwapHash: swapHash, HtlcCltvExpiry: 2_000, InitiationHeight: uint32(mockLnd.Height), - InitiationTime: time.Now(), + InitiationTime: time.Now().Add(-time.Hour), ProtocolVersion: version.ProtocolVersion_V0, ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), @@ -2081,12 +2535,13 @@ type depositTransition struct { type recordingDepositManager struct { noopDepositManager - err error - transitions []depositTransition - transitionChan chan depositTransition + err error + errs []error + transitions []depositTransition - events []fsm.EventType - states []fsm.StateType + transitionChan chan depositTransition + events []fsm.EventType + states []fsm.StateType } // TransitionDeposits records the transition and returns the configured error. @@ -2108,9 +2563,43 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, r.transitionChan <- transition } + if len(r.errs) > 0 { + err := r.errs[0] + r.errs = r.errs[1:] + + return err + } + return r.err } +type recordingRiskStore struct { + *mockStore + + decisions chan ConfirmationRiskDecision +} + +// RecordStaticAddressRiskDecision records a risk decision in the mock store. +func (s *recordingRiskStore) RecordStaticAddressRiskDecision( + _ context.Context, swapHash lntypes.Hash, + decision ConfirmationRiskDecision) error { + + loopIn, ok := s.loopIns[swapHash] + if !ok { + return ErrLoopInNotFound + } + + loopIn.ConfirmationRiskDecision = decision + loopIn.ConfirmationRiskDecisionTime = time.Now() + + select { + case s.decisions <- decision: + default: + } + + return nil +} + // mockNotificationManager allows tests to push server notifications directly to // monitor actions. type mockNotificationManager struct { diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index ab8dc45f7..d54355a7b 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -91,6 +91,11 @@ type StaticAddressLoopInStore interface { // IsStored checks if the loop-in is already stored in the database. IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error) + // RecordStaticAddressRiskDecision persists the server's + // confirmation-risk decision for the loop-in identified by swapHash. + RecordStaticAddressRiskDecision(ctx context.Context, + swapHash lntypes.Hash, decision ConfirmationRiskDecision) error + // GetLoopInByHash returns the loop-in swap with the given hash. GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) ( *StaticAddressLoopIn, error) diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 541f0826b..564429c60 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -524,6 +524,13 @@ func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { return false, nil } +// RecordStaticAddressRiskDecision implements Store for manager tests. +func (s *mockStore) RecordStaticAddressRiskDecision(context.Context, + lntypes.Hash, ConfirmationRiskDecision) error { + + return nil +} + func (s *mockStore) GetLoopInByHash(_ context.Context, swapHash lntypes.Hash) (*StaticAddressLoopIn, error) { From ed91300da18c6cd8988b39b07ca27060918fc087 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 9 Jul 2026 10:32:21 +0200 Subject: [PATCH 18/35] staticaddr/loopin: add risk decision watcher --- staticaddr/loopin/risk_watcher.go | 182 +++++++++++++++++++++++++ staticaddr/loopin/risk_watcher_test.go | 121 ++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 staticaddr/loopin/risk_watcher.go create mode 100644 staticaddr/loopin/risk_watcher_test.go diff --git a/staticaddr/loopin/risk_watcher.go b/staticaddr/loopin/risk_watcher.go new file mode 100644 index 000000000..4da95a205 --- /dev/null +++ b/staticaddr/loopin/risk_watcher.go @@ -0,0 +1,182 @@ +package loopin + +import ( + "bytes" + "context" + "time" + + "github.com/lightningnetwork/lnd/lntypes" +) + +// confirmationRiskUpdate is the normalized result of a server confirmation-risk +// notification. +type confirmationRiskUpdate struct { + decision ConfirmationRiskDecision + reason string +} + +// confirmationRiskWatcher normalizes static loop-in confirmation risk +// notifications and restores the durable decision timestamp. +type confirmationRiskWatcher struct { + swapHash lntypes.Hash + store StaticAddressLoopInStore + notificationManager NotificationManager + logWarnf func(string, ...any) +} + +// newConfirmationRiskWatcher creates a helper that handles confirmation-risk +// notification plumbing for a single static loop-in swap. +func newConfirmationRiskWatcher(cfg *Config, swapHash lntypes.Hash, + warnf func(string, ...any)) *confirmationRiskWatcher { + + return &confirmationRiskWatcher{ + swapHash: swapHash, + store: cfg.Store, + notificationManager: cfg.NotificationManager, + logWarnf: warnf, + } +} + +// warnf logs through the FSM-scoped logger when one is available. +func (w *confirmationRiskWatcher) warnf(format string, args ...any) { + if w.logWarnf != nil { + w.logWarnf(format, args...) + + return + } + + log.Warnf(format, args...) +} + +// subscribe subscribes to accepted and rejected confirmation-risk notifications +// and emits normalized updates for the watcher's swap hash. +func (w *confirmationRiskWatcher) subscribe(ctx context.Context) ( + <-chan confirmationRiskUpdate, func()) { + + if w.notificationManager == nil { + return nil, func() {} + } + + notificationCtx, cancel := context.WithCancel(ctx) + riskAcceptedChan := w.notificationManager.SubscribeStaticLoopInRiskAccepted( + notificationCtx, w.swapHash, + ) + riskRejectedChan := w.notificationManager.SubscribeStaticLoopInRiskRejected( + notificationCtx, w.swapHash, + ) + + riskUpdates := make(chan confirmationRiskUpdate, 1) + go func() { + defer close(riskUpdates) + + for { + select { + case riskAccepted, ok := <-riskAcceptedChan: + if !ok { + riskAcceptedChan = nil + continue + } + + if riskAccepted == nil || !bytes.Equal( + riskAccepted.SwapHash, w.swapHash[:], + ) { + + continue + } + + update := confirmationRiskUpdate{ + decision: ConfirmationRiskDecisionAccepted, + reason: "risk accepted notification", + } + select { + case riskUpdates <- update: + case <-notificationCtx.Done(): + return + } + + case riskRejected, ok := <-riskRejectedChan: + if !ok { + riskRejectedChan = nil + continue + } + + if riskRejected == nil || !bytes.Equal( + riskRejected.SwapHash, w.swapHash[:], + ) { + + continue + } + + update := confirmationRiskUpdate{ + decision: ConfirmationRiskDecisionRejected, + reason: "risk rejection", + } + select { + case riskUpdates <- update: + case <-notificationCtx.Done(): + return + } + + case <-notificationCtx.Done(): + return + } + } + }() + + return riskUpdates, cancel +} + +// decisionTime returns the durable decision timestamp, recording the decision +// first if the notification was replayed before it could be persisted. +func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, + decision ConfirmationRiskDecision) time.Time { + + now := time.Now() + if w.store == nil { + return now + } + + storedLoopIn, err := w.store.GetLoopInByHash(ctx, w.swapHash) + if err != nil { + w.warnf("unable to reload persisted risk decision for swap %v: %v", + w.swapHash, err) + + return now + } + + if storedLoopIn == nil { + return now + } + + hasPersistedDecision := + storedLoopIn.ConfirmationRiskDecision == decision && + !storedLoopIn.ConfirmationRiskDecisionTime.IsZero() + + if !hasPersistedDecision { + err = w.store.RecordStaticAddressRiskDecision( + ctx, w.swapHash, decision, + ) + if err != nil { + w.warnf("unable to persist replayed risk decision for "+ + "swap %v: %v", w.swapHash, err) + + return now + } + + storedLoopIn, err = w.store.GetLoopInByHash(ctx, w.swapHash) + if err != nil { + w.warnf("unable to reload persisted risk decision for "+ + "swap %v: %v", w.swapHash, err) + + return now + } + if storedLoopIn == nil || + storedLoopIn.ConfirmationRiskDecision != decision || + storedLoopIn.ConfirmationRiskDecisionTime.IsZero() { + + return now + } + } + + return storedLoopIn.ConfirmationRiskDecisionTime +} diff --git a/staticaddr/loopin/risk_watcher_test.go b/staticaddr/loopin/risk_watcher_test.go new file mode 100644 index 000000000..4f907615d --- /dev/null +++ b/staticaddr/loopin/risk_watcher_test.go @@ -0,0 +1,121 @@ +package loopin + +import ( + "context" + "testing" + "time" + + "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" +) + +// TestConfirmationRiskWatcherSubscribeFiltersSwapHash verifies that the watcher +// only emits normalized decisions for the swap it was created for. +func TestConfirmationRiskWatcherSubscribeFiltersSwapHash(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + swapHash := lntypes.Hash{1, 2, 3} + otherHash := lntypes.Hash{3, 2, 1} + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + riskRejected: make( + chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, 1, + ), + } + + watcher := newConfirmationRiskWatcher( + &Config{NotificationManager: notificationMgr}, swapHash, + t.Logf, + ) + updates, stop := watcher.subscribe(ctx) + defer stop() + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: otherHash[:], + } + + select { + case update := <-updates: + t.Fatalf("received wrong-hash risk update: %v", update) + + case <-time.After(100 * time.Millisecond): + } + + notificationMgr.riskRejected <- &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: swapHash[:], + } + + select { + case update := <-updates: + require.Equal(t, ConfirmationRiskDecisionRejected, + update.decision) + require.Equal(t, "risk rejection", update.reason) + + case <-ctx.Done(): + t.Fatalf("risk update not received: %v", ctx.Err()) + } +} + +// TestConfirmationRiskWatcherDecisionTimeRestoration verifies that the watcher +// preserves existing persisted decision timestamps and records missing ones. +func TestConfirmationRiskWatcherDecisionTimeRestoration(t *testing.T) { + t.Parallel() + + ctx := t.Context() + swapHash := lntypes.Hash{4, 5, 6} + decisionTime := time.Unix(123, 0).UTC() + store := &recordingRiskStore{ + mockStore: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: { + ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted, + ConfirmationRiskDecisionTime: decisionTime, + }, + }, + }, + decisions: make(chan ConfirmationRiskDecision, 1), + } + + watcher := newConfirmationRiskWatcher(&Config{Store: store}, swapHash, + t.Logf) + restoredTime := watcher.decisionTime( + ctx, ConfirmationRiskDecisionAccepted, + ) + require.True(t, restoredTime.Equal(decisionTime)) + + select { + case decision := <-store.decisions: + t.Fatalf("persisted already-recorded decision: %v", decision) + + default: + } + + store.loopIns[swapHash] = &StaticAddressLoopIn{} + recordedTime := watcher.decisionTime( + ctx, ConfirmationRiskDecisionRejected, + ) + require.False(t, recordedTime.IsZero()) + + select { + case decision := <-store.decisions: + require.Equal(t, ConfirmationRiskDecisionRejected, decision) + + case <-time.After(time.Second): + t.Fatal("missing risk decision was not persisted") + } + require.Equal(t, ConfirmationRiskDecisionRejected, + store.loopIns[swapHash].ConfirmationRiskDecision) + require.True(t, recordedTime.Equal( + store.loopIns[swapHash].ConfirmationRiskDecisionTime, + )) +} From 4fe2ee284502b012945dc27806c192d8e2aa9cda Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 9 Jul 2026 10:32:30 +0200 Subject: [PATCH 19/35] staticaddr/loopin: use risk decision watcher --- staticaddr/loopin/actions.go | 366 ++++++++++--------- staticaddr/loopin/actions_test.go | 588 +++++++++++++++++++++++++++++- staticaddr/loopin/risk_watcher.go | 36 +- 3 files changed, 796 insertions(+), 194 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 47ffbbe77..2ae1a7eb0 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,7 +1,6 @@ package loopin import ( - "bytes" "context" "crypto/rand" "errors" @@ -40,6 +39,8 @@ const ( DefaultPaymentTimeoutSeconds = 60 defaultInvoiceCleanupTimeout = 5 * time.Second + + monitorRetryDelay = time.Second ) var ( @@ -70,7 +71,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, return } - f.cancelSwapInvoice() + _ = f.cancelSwapInvoice() }() returnError := func(err error) fsm.EventType { @@ -342,11 +343,12 @@ func (f *FSM) InitHtlcAction(ctx context.Context, return event } -// cancelSwapInvoice best-effort cancels the current swap invoice using a -// detached timeout-limited context. -func (f *FSM) cancelSwapInvoice() { +// cancelSwapInvoice cancels the current swap invoice using a detached, +// timeout-limited context. Callers that must not proceed while the invoice may +// still be payable can use the returned error to retry. +func (f *FSM) cancelSwapInvoice() error { if f.loopIn.SwapHash == (lntypes.Hash{}) { - return + return nil } cleanupCtx, cancel := context.WithTimeout( @@ -359,6 +361,8 @@ func (f *FSM) cancelSwapInvoice() { f.Warnf("unable to cancel invoice for swap %v: %v", f.loopIn.SwapHash, err) } + + return err } // handleInvoiceUpdate applies the monitor state's invoice-update semantics and @@ -384,9 +388,13 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( return fsm.NoOp, false default: - err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+ - "canceled", update.State, f.loopIn.SwapHash) - return f.HandleError(err), true + // An unknown state is not evidence that the invoice can no longer + // settle. Keep monitoring rather than leaving the deposits available + // for reuse. + f.Warnf("unexpected invoice state %v for swap hash %v", + update.State, f.loopIn.SwapHash) + + return fsm.NoOp, false } } @@ -556,7 +564,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, if outpointUnavailable { err = errors.New("original deposit outpoint no longer available") f.Warnf("%v, canceling swap invoice", err) - f.cancelSwapInvoice() + _ = f.cancelSwapInvoice() return f.HandleError(err) } @@ -782,6 +790,28 @@ func (f *FSM) cleanUpSessions(ctx context.Context, func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { + retryMonitor := func(err error) fsm.EventType { + f.Errorf("monitoring failed: %v, retrying", err) + + invoice, lookupErr := f.cfg.LndClient.LookupInvoice( + ctx, f.loopIn.SwapHash, + ) + if lookupErr == nil && invoice.State == invoices.ContractSettled { + return OnPaymentReceived + } + + retryTimer := time.NewTimer(monitorRetryDelay) + defer retryTimer.Stop() + + select { + case <-retryTimer.C: + return OnRecover + + case <-ctx.Done(): + return fsm.NoOp + } + } + // Subscribe to the state of the swap invoice. If upon restart recovery, // we land here and observe that the invoice is already canceled, it can // only be the case where a user-provided payment timeout was hit, the @@ -804,18 +834,20 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, err = fmt.Errorf("unable to subscribe to swap "+ "invoice: %w", err) - return f.HandleError(err) + return retryMonitor(err) } htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams) if err != nil { err = fmt.Errorf("unable to get htlc: %w", err) - return f.HandleError(err) + return retryMonitor(err) } // Subscribe to htlc tx confirmation. reorgChan := make(chan struct{}, 1) + // registerHtlcConf registers for the HTLC transaction confirmation using + // the current reorg channel. registerHtlcConf := func() (chan *chainntnfs.TxConfirmation, chan error, error) { @@ -835,7 +867,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, err = fmt.Errorf("unable to monitor htlc tx confirmation: %w", err) - return f.HandleError(err) + return retryMonitor(err) } // Subscribe to new blocks. @@ -848,28 +880,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, err = fmt.Errorf("unable to subscribe to new blocks: %w", err) - return f.HandleError(err) + return retryMonitor(err) } - var ( - riskAcceptedChan <-chan *swapserverrpc. - ServerStaticLoopInRiskAcceptedNotification - riskRejectedChan <-chan *swapserverrpc. - ServerStaticLoopInRiskRejectedNotification - cancelRiskNotificationSubscriptions = func() {} + // The watcher keeps notification normalization and timestamp restoration + // outside of the swap-state handling below. + riskWatcher := newConfirmationRiskWatcher( + f.cfg, f.loopIn.SwapHash, f.Warnf, ) - if f.cfg.NotificationManager != nil { - notificationCtx, cancel := context.WithCancel(ctx) - cancelRiskNotificationSubscriptions = cancel - riskAcceptedChan = f.cfg.NotificationManager. - SubscribeStaticLoopInRiskAccepted( - notificationCtx, f.loopIn.SwapHash, - ) - riskRejectedChan = f.cfg.NotificationManager. - SubscribeStaticLoopInRiskRejected( - notificationCtx, f.loopIn.SwapHash, - ) - } + riskUpdateChan, cancelRiskNotificationSubscriptions := + riskWatcher.subscribe(ctx) defer cancelRiskNotificationSubscriptions() // Look up the current invoice state after registering subscriptions so @@ -880,10 +900,24 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return fsm.NoOp } - err = fmt.Errorf("unable to look up invoice by swap hash: %w", - err) + // A failed lookup leaves the invoice state unknown. The active + // subscription can still provide an authoritative update, so keep + // monitoring and, most importantly, keep the deposits locked. + f.Warnf("unable to look up invoice by swap hash: %v", err) + invoice = &lndclient.Invoice{} + } - return f.HandleError(err) + // A settled invoice always takes precedence over a recovered risk + // rejection or an elapsed payment deadline. + if invoice.State == invoices.ContractSettled { + return OnPaymentReceived + } + + invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled + if invoiceCanceledForNonPayment { + // If the invoice was canceled previously we end our + // subscription to invoice updates. + cancelInvoiceSubscription() } // Create the swap payment timeout timer after the server confirms @@ -894,12 +928,15 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, deadlineTimer *time.Timer deadlineStarted bool ) + // Stop the payment deadline timer when leaving the monitor action. defer func() { if deadlineTimer != nil { deadlineTimer.Stop() } }() + // depositsInState reports whether all selected deposits are currently + // in the requested state. depositsInState := func(state fsm.StateType) bool { if len(f.loopIn.Deposits) == 0 { return false @@ -910,10 +947,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return false } - d.Lock() - inState := d.IsInStateNoLock(state) - d.Unlock() - if !inState { + if !d.IsInState(state) { return false } } @@ -921,11 +955,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return true } - invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled - depositsLockedForHtlcTimeout := depositsInState( - deposit.SweepHtlcTimeout, - ) - + // startPaymentDeadline arms the server payment timeout from the decision + // time when one is available. startPaymentDeadline := func(reason string, startedAt time.Time) { if deadlineStarted || invoice.State == invoices.ContractCanceled { return @@ -945,6 +976,12 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, deadlineStarted = true } + depositsLockedForHtlcTimeout := depositsInState( + deposit.SweepHtlcTimeout, + ) + + // transitionDepositsToHtlcTimeout locks deposits into timeout sweeping once + // the HTLC is confirmed and the invoice cannot be paid. transitionDepositsToHtlcTimeout := func(reason string) { if depositsLockedForHtlcTimeout || depositsInState(deposit.SweepHtlcTimeout) { @@ -969,111 +1006,64 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, depositsLockedForHtlcTimeout = true } + // startLegacyFallback starts the payment deadline once the old local + // minimum-confirmation rule has been satisfied. startLegacyFallback := func(reason string, currentHeight int32) { - if deadlineStarted || invoice.State == invoices.ContractCanceled { + if deadlineStarted || invoice.State == invoices.ContractCanceled || + f.loopIn.ConfirmationRiskDecision != + ConfirmationRiskDecisionNone { + return } if f.shouldStartLegacyConfirmationFallback(ctx, currentHeight) { - startPaymentDeadline(reason, time.Time{}) - } - } - - if invoice.State == invoices.ContractCanceled { - // If the invoice was canceled previously we end our - // subscription to invoice updates. - cancelInvoiceSubscription() - } - - cancelInvoice := func() { - f.Errorf("timeout waiting for invoice to be " + - "paid, canceling invoice") - - // Cancel the lndclient invoice subscription. - cancelInvoiceSubscription() - - // Reuse the same helper as InitHtlcAction so timeout cleanup - // follows the same detached-context path as early-init cleanup. - f.cancelSwapInvoice() - invoice.State = invoices.ContractCanceled - invoiceCanceledForNonPayment = true - } - - riskDecisionTime := func(decision ConfirmationRiskDecision) time.Time { - now := time.Now() - if f.cfg.Store == nil { - return now - } - - storedLoopIn, err := f.cfg.Store.GetLoopInByHash( - ctx, f.loopIn.SwapHash, - ) - if err != nil { - f.Warnf("unable to reload persisted risk decision for "+ - "swap %v: %v", f.loopIn.SwapHash, err) - - return now - } - - if storedLoopIn == nil { - return now - } - - hasPersistedDecision := - storedLoopIn.ConfirmationRiskDecision == decision && - !storedLoopIn.ConfirmationRiskDecisionTime.IsZero() - - if !hasPersistedDecision { - err = f.cfg.Store.RecordStaticAddressRiskDecision( - ctx, f.loopIn.SwapHash, decision, + decisionTime, ok := riskWatcher.durableDecisionTime( + ctx, ConfirmationRiskDecisionAccepted, ) - if err != nil { - f.Warnf("unable to persist replayed risk "+ - "decision for swap %v: %v", - f.loopIn.SwapHash, err) - - return now + if !ok { + return } - storedLoopIn, err = f.cfg.Store.GetLoopInByHash( - ctx, f.loopIn.SwapHash, - ) - if err != nil { - f.Warnf("unable to reload persisted risk "+ - "decision for swap %v: %v", - f.loopIn.SwapHash, err) - - return now - } - if storedLoopIn == nil || - storedLoopIn.ConfirmationRiskDecision != decision || - storedLoopIn.ConfirmationRiskDecisionTime.IsZero() { + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionAccepted + f.loopIn.ConfirmationRiskDecisionTime = decisionTime + startPaymentDeadline(reason, decisionTime) + } + } - return now + // cancelInvoice only marks the invoice canceled after lnd acknowledges + // the request or the lookup/subscription already observed that state. + // Failures recover the monitor state without releasing deposits. + cancelInvoice := func(reason string) (fsm.EventType, bool) { + if invoice.State != invoices.ContractCanceled { + f.Errorf("%s, canceling invoice", reason) + if err := f.cancelSwapInvoice(); err != nil { + return retryMonitor(err), false } } - f.loopIn.ConfirmationRiskDecision = - storedLoopIn.ConfirmationRiskDecision - f.loopIn.ConfirmationRiskDecisionTime = - storedLoopIn.ConfirmationRiskDecisionTime - - return storedLoopIn.ConfirmationRiskDecisionTime - } - - handleRiskRejected := func(reason string) fsm.EventType { cancelInvoiceSubscription() - f.cancelSwapInvoice() invoice.State = invoices.ContractCanceled invoiceCanceledForNonPayment = true - decisionTime := riskDecisionTime( - ConfirmationRiskDecisionRejected, - ) + + return fsm.NoOp, true + } + + // handleRiskRejected records a server rejection and only exits through + // the generic error path once the invoice can no longer settle. + handleRiskRejected := func(reason string, + decisionTime time.Time) fsm.EventType { + f.loopIn.ConfirmationRiskDecision = ConfirmationRiskDecisionRejected f.loopIn.ConfirmationRiskDecisionTime = decisionTime - riskAcceptedChan = nil - riskRejectedChan = nil + + event, canceled := cancelInvoice( + "server rejected confirmation risk wait after " + reason, + ) + if !canceled { + return event + } return f.HandleError(fmt.Errorf( "server rejected confirmation risk wait after %s", reason, @@ -1088,7 +1078,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, ) case ConfirmationRiskDecisionRejected: - return handleRiskRejected("recovered risk rejection") + decisionTime := riskWatcher.decisionTime( + ctx, ConfirmationRiskDecisionRejected, + ) + + return handleRiskRejected( + "recovered risk rejection", decisionTime, + ) } info, err := f.cfg.LndClient.GetInfo(ctx) @@ -1136,7 +1132,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, err = fmt.Errorf("unable to re-register for "+ "htlc tx confirmation: %w", err) - return f.HandleError(err) + return retryMonitor(err) } case <-reorgChan: @@ -1154,7 +1150,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, err = fmt.Errorf("unable to monitor htlc tx "+ "confirmation: %v", err) - return f.HandleError(err) + return retryMonitor(err) } case <-deadlineChan: @@ -1163,7 +1159,12 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // If the server didn't pay the invoice on time, we cancel // it and keep monitoring the htlc tx. Confirmed HTLC // deposits remain locked for timeout sweeping. - cancelInvoice() + event, canceled := cancelInvoice( + "timeout waiting for invoice to be paid", + ) + if !canceled { + return event + } if htlcConfirmed { transitionDepositsToHtlcTimeout("payment deadline") continue @@ -1175,45 +1176,31 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, "payment deadline: %v", err) } - case riskAccepted, ok := <-riskAcceptedChan: + case riskUpdate, ok := <-riskUpdateChan: if !ok { - riskAcceptedChan = nil + riskUpdateChan = nil continue } - if !bytes.Equal( - riskAccepted.SwapHash, f.loopIn.SwapHash[:], - ) { - - continue - } - - startedAt := riskDecisionTime( - ConfirmationRiskDecisionAccepted, + decisionTime := riskWatcher.decisionTime( + ctx, riskUpdate.decision, ) - f.loopIn.ConfirmationRiskDecision = - ConfirmationRiskDecisionAccepted - f.loopIn.ConfirmationRiskDecisionTime = startedAt - startPaymentDeadline( - "risk accepted notification", - f.loopIn.ConfirmationRiskDecisionTime, - ) - - case riskRejected, ok := <-riskRejectedChan: - if !ok { - riskRejectedChan = nil - continue - } - - if !bytes.Equal( - riskRejected.SwapHash, f.loopIn.SwapHash[:], - ) { + f.loopIn.ConfirmationRiskDecision = riskUpdate.decision + f.loopIn.ConfirmationRiskDecisionTime = decisionTime + + switch riskUpdate.decision { + case ConfirmationRiskDecisionAccepted: + startPaymentDeadline( + riskUpdate.reason, + f.loopIn.ConfirmationRiskDecisionTime, + ) - continue + case ConfirmationRiskDecisionRejected: + return handleRiskRejected( + riskUpdate.reason, decisionTime, + ) } - return handleRiskRejected("risk rejection") - case currentHeight := <-blockChan: startLegacyFallback( "legacy confirmation fallback", currentHeight, @@ -1236,10 +1223,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.Infof("htlc timed out at block height %v", currentHeight) - - // If the timeout path opened up we consider the swap - // failed and cancel the invoice. - cancelInvoice() + event, canceled := cancelInvoice("htlc timed out") + if !canceled { + return event + } if !htlcConfirmed { f.Infof("swap timed out, htlc not confirmed") @@ -1247,13 +1234,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // If the htlc hasn't confirmed but the timeout // path opened up, and we didn't receive the // swap payment, we consider the swap attempt to - // be failed. Now that the HTLC can no longer - // confirm, the deposits can be made available - // again. + // be failed. Now that the invoice is canceled and + // the HTLC can no longer confirm, its deposits can be + // made available again. err = f.unlockDeposits(ctx) if err != nil { - f.Errorf("unable to unlock deposits "+ - "after htlc timeout: %v", err) + f.Errorf("unable to unlock deposits after "+ + "htlc timeout: %v", err) } return OnSwapTimedOut @@ -1272,10 +1259,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.Errorf("block subscription error: %v", err) - return f.HandleError(err) + return retryMonitor(err) case update, ok := <-invoiceUpdateChan: if !ok { + if !invoiceCanceledForNonPayment { + return retryMonitor(errors.New( + "invoice update subscription closed", + )) + } + invoiceUpdateChan = nil continue } @@ -1284,13 +1277,30 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return event } + invoice.State = update.State + if update.State == invoices.ContractCanceled { + invoiceCanceledForNonPayment = true + } + case err, ok := <-invoiceErrChan: if !ok { + if !invoiceCanceledForNonPayment { + return retryMonitor(errors.New( + "invoice error subscription closed", + )) + } + invoiceErrChan = nil continue } - f.Errorf("invoice subscription error: %v", err) + if ctx.Err() != nil { + return fsm.NoOp + } + + return retryMonitor(fmt.Errorf( + "invoice subscription error: %w", err, + )) case <-ctx.Done(): return fsm.NoOp @@ -1425,7 +1435,7 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context, func (f *FSM) UnlockDepositsAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - f.cancelSwapInvoice() + _ = f.cancelSwapInvoice() err := f.unlockDeposits(ctx) if err != nil { diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 030aed300..224872ae4 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -60,11 +60,9 @@ func TestHandleInvoiceUpdate(t *testing.T) { event: fsm.NoOp, }, { - name: "unexpected", - state: invoices.ContractState(99), - event: fsm.OnError, - done: true, - errString: "unexpected invoice state", + name: "unexpected", + state: invoices.ContractState(99), + event: fsm.NoOp, }, } @@ -103,6 +101,298 @@ func TestHandleInvoiceUpdate(t *testing.T) { } } +// TestMonitorInvoiceSettledWinsOverRecoveredRiskRejection verifies that an +// authoritative settled state takes precedence over a persisted server risk +// rejection during recovery. +func TestMonitorInvoiceSettledWinsOverRecoveredRiskRejection(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{1, 2, 5} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractSettled, + }) + + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected, + mockLnd.LndServices.Invoices, + ) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case event := <-resultChan: + require.Equal(t, OnPaymentReceived, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Empty(t, depositMgr.transitions) + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("settled invoice was canceled: %v", hash) + + default: + } +} + +// TestMonitorInvoiceCancelErrorKeepsMonitoring verifies that cancellation +// failures neither unlock deposits nor stop invoice monitoring. Recovery +// rechecks the authoritative invoice state, and a later settlement wins. +func TestMonitorInvoiceCancelErrorKeepsMonitoring(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{1, 2, 6} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cancelCalls := make(chan lntypes.Hash, 2) + releaseCancel := make(chan struct{}) + invoicesClient := &failingCancelInvoices{ + InvoicesClient: mockLnd.LndServices.Invoices, + cancelCalls: cancelCalls, + release: releaseCancel, + err: errors.New("invoice backend unavailable"), + } + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected, + invoicesClient, + ) + f.ActionEntryFunc = nil + + resultChan := make(chan error, 1) + go func() { + resultChan <- f.SendEvent(ctx, OnRecover, nil) + }() + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-cancelCalls: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("cancellation attempt not received: %v", ctx.Err()) + } + + select { + case transition := <-depositMgr.transitionChan: + t.Fatalf("deposits unlocked after cancellation error: %v", + transition) + + default: + } + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractSettled, + }) + close(releaseCancel) + + select { + case err := <-resultChan: + require.NoError(t, err) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states) +} + +// TestMonitorInvoiceUnknownStateKeepsMonitoring verifies that a failed lookup +// and an unknown subscription state do not release deposits. A subsequent +// authoritative settlement still completes the swap. +func TestMonitorInvoiceUnknownStateKeepsMonitoring(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{1, 2, 7} + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone, + mockLnd.LndServices.Invoices, + ) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + var invoiceSub *test.SingleInvoiceSubscription + select { + case invoiceSub = <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } + + invoiceSub.Update <- lndclient.InvoiceUpdate{ + Invoice: lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractState(99), + }, + } + + select { + case event := <-resultChan: + t.Fatalf("unknown invoice state ended monitor with %v", event) + + case transition := <-depositMgr.transitionChan: + t.Fatalf("unknown invoice state unlocked deposits: %v", transition) + + case <-time.After(100 * time.Millisecond): + } + + invoiceSub.Update <- lndclient.InvoiceUpdate{ + Invoice: lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractSettled, + }, + } + + select { + case event := <-resultChan: + require.Equal(t, OnPaymentReceived, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Empty(t, depositMgr.transitions) +} + +// TestMonitorInvoiceSetupFailureRecoversSettledInvoice verifies that a +// transient subscription failure cannot route an already-settled swap through +// deposit cleanup before the authoritative invoice lookup runs. +func TestMonitorInvoiceSetupFailureRecoversSettledInvoice(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{1, 2, 8} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractSettled, + }) + invoicesClient := &flakySubscribeInvoices{ + InvoicesClient: mockLnd.LndServices.Invoices, + err: errors.New("invoice backend unavailable"), + } + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionRejected, + invoicesClient, + ) + f.ActionEntryFunc = nil + + resultChan := make(chan error, 1) + go func() { + resultChan <- f.SendEvent(ctx, OnRecover, nil) + }() + + select { + case err := <-resultChan: + require.NoError(t, err) + + case <-ctx.Done(): + t.Fatalf("monitor did not recover: %v", ctx.Err()) + } + + require.Equal(t, 1, invoicesClient.subscribeCalls) + require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states) + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("settled invoice was canceled: %v", hash) + + default: + } +} + +// TestMonitorInvoiceStreamErrorRecoversSettlement verifies that a dead invoice +// stream checks the latest invoice state before entering recovery. +func TestMonitorInvoiceStreamErrorRecoversSettlement(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + swapHash := lntypes.Hash{1, 2, 9} + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + f, depositMgr := newInvoiceMonitorTestFSM( + t, ctx, mockLnd, swapHash, ConfirmationRiskDecisionNone, + mockLnd.LndServices.Invoices, + ) + lookupStarted := make(chan struct{}) + releaseLookup := make(chan struct{}) + f.cfg.LndClient = &firstLookupBarrier{ + LightningClient: mockLnd.Client, + lookupStarted: lookupStarted, + release: releaseLookup, + } + f.ActionEntryFunc = nil + + resultChan := make(chan error, 1) + go func() { + resultChan <- f.SendEvent(ctx, OnRecover, nil) + }() + + var invoiceSub *test.SingleInvoiceSubscription + select { + case invoiceSub = <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } + select { + case <-lookupStarted: + case <-ctx.Done(): + t.Fatalf("initial invoice lookup not received: %v", ctx.Err()) + } + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractSettled, + }) + close(releaseLookup) + select { + case invoiceSub.Err <- errors.New("invoice stream failed"): + case <-ctx.Done(): + t.Fatalf("invoice stream error was not consumed: %v", ctx.Err()) + } + + select { + case err := <-resultChan: + require.NoError(t, err) + + case <-ctx.Done(): + t.Fatalf("monitor did not recover: %v", ctx.Err()) + } + + require.Equal(t, []fsm.StateType{deposit.LoopedIn}, depositMgr.states) +} + // TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr ensures that an error from // the HTLC confirmation subscription triggers a re-registration. Without the // regression fix, only the initial registration would be performed and the @@ -806,6 +1096,144 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { } } +// TestMonitorInvoiceAndHtlcTxIgnoresWrongHashRiskNotifications verifies that +// risk notifications for another swap do not start the payment deadline or +// persist a decision through the monitor action. +func TestMonitorInvoiceAndHtlcTxIgnoresWrongHashRiskNotifications( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{4, 5, 8} + otherHash := lntypes.Hash{8, 5, 4} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 2, + ), + riskRejected: make( + chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, 1, + ), + } + store := &recordingRiskStore{ + mockStore: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: {}, + }, + }, + decisions: make(chan ConfirmationRiskDecision, 1), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + Store: store, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: otherHash[:], + } + notificationMgr.riskRejected <- &swapserverrpc. + ServerStaticLoopInRiskRejectedNotification{ + SwapHash: otherHash[:], + } + + select { + case decision := <-store.decisions: + t.Fatalf("persisted wrong-hash risk decision: %v", decision) + + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("canceled invoice for wrong-hash risk decision: %v", hash) + + case event := <-resultChan: + t.Fatalf("monitor action exited after wrong-hash risk decision: %v", + event) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case decision := <-store.decisions: + require.Equal(t, ConfirmationRiskDecisionAccepted, decision) + + case <-ctx.Done(): + t.Fatalf("risk decision was not persisted: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + // TestMonitorInvoiceAndHtlcTxUsesPersistedAcceptedRiskTime verifies that live // risk notifications use the durable receipt time, not the local channel // receive time, when reconstructing the payment deadline. @@ -1805,6 +2233,14 @@ func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight( Hash: swapHash, State: invoices.ContractOpen, }) + store := &recordingRiskStore{ + mockStore: &mockStore{ + loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ + swapHash: {}, + }, + }, + decisions: make(chan ConfirmationRiskDecision, 1), + } cfg := &Config{ AddressManager: &mockAddressManager{ @@ -1823,6 +2259,7 @@ func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight( InvoicesClient: mockLnd.LndServices.Invoices, LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, + Store: store, } f, err := NewFSM(ctx, loopIn, cfg, false) @@ -1835,6 +2272,19 @@ func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight( waitForMonitorSubscriptions(t, ctx, mockLnd) + select { + case decision := <-store.decisions: + require.Equal(t, ConfirmationRiskDecisionAccepted, decision) + + case <-ctx.Done(): + t.Fatalf("legacy fallback decision was not persisted: %v", + ctx.Err()) + } + require.Equal(t, ConfirmationRiskDecisionAccepted, + store.loopIns[swapHash].ConfirmationRiskDecision) + require.False(t, + store.loopIns[swapHash].ConfirmationRiskDecisionTime.IsZero()) + select { case hash := <-mockLnd.FailInvoiceChannel: t.Fatalf("invoice canceled before payment deadline: %v", hash) @@ -2139,6 +2589,134 @@ func waitForMonitorSubscriptions(t *testing.T, ctx context.Context, } } +// newInvoiceMonitorTestFSM creates the minimal monitor-state setup shared by +// invoice precedence and cancellation tests. +func newInvoiceMonitorTestFSM(t *testing.T, ctx context.Context, + mockLnd *test.LndMockServices, swapHash lntypes.Hash, + decision ConfirmationRiskDecision, + invoicesClient lndclient.InvoicesClient) (*FSM, *recordingDepositManager) { + + t.Helper() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: mockLnd.Height + 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + ConfirmationRiskDecision: decision, + ConfirmationRiskDecisionTime: time.Now(), + Deposits: []*deposit.Deposit{{ + Value: 200_000, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + depositMgr := &recordingDepositManager{ + transitionChan: make(chan depositTransition, 1), + } + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: invoicesClient, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, true) + require.NoError(t, err) + + return f, depositMgr +} + +type failingCancelInvoices struct { + lndclient.InvoicesClient + + cancelCalls chan lntypes.Hash + release chan struct{} + err error +} + +type flakySubscribeInvoices struct { + lndclient.InvoicesClient + + subscribeCalls int + err error +} + +type firstLookupBarrier struct { + lndclient.LightningClient + + lookupStarted chan struct{} + release chan struct{} + firstLookup bool +} + +func (f *firstLookupBarrier) LookupInvoice(ctx context.Context, + hash lntypes.Hash) (*lndclient.Invoice, error) { + + invoice, err := f.LightningClient.LookupInvoice(ctx, hash) + if f.firstLookup { + return invoice, err + } + + f.firstLookup = true + close(f.lookupStarted) + select { + case <-f.release: + return invoice, err + + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (f *flakySubscribeInvoices) SubscribeSingleInvoice(ctx context.Context, + hash lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) { + + f.subscribeCalls++ + if f.subscribeCalls == 1 { + return nil, nil, f.err + } + + return f.InvoicesClient.SubscribeSingleInvoice(ctx, hash) +} + +func (f *failingCancelInvoices) CancelInvoice(ctx context.Context, + hash lntypes.Hash) error { + + select { + case f.cancelCalls <- hash: + case <-ctx.Done(): + return ctx.Err() + } + + if f.release != nil { + select { + case <-f.release: + case <-ctx.Done(): + return ctx.Err() + } + } + + return f.err +} + // TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a // present txout does not trigger the RBF cancellation path. func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) { diff --git a/staticaddr/loopin/risk_watcher.go b/staticaddr/loopin/risk_watcher.go index 4da95a205..6df9a7457 100644 --- a/staticaddr/loopin/risk_watcher.go +++ b/staticaddr/loopin/risk_watcher.go @@ -126,14 +126,16 @@ func (w *confirmationRiskWatcher) subscribe(ctx context.Context) ( return riskUpdates, cancel } -// decisionTime returns the durable decision timestamp, recording the decision -// first if the notification was replayed before it could be persisted. -func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, - decision ConfirmationRiskDecision) time.Time { +// durableDecisionTime returns the durable decision timestamp, recording the +// decision first if the notification was replayed before it could be persisted. +// The bool is false when a configured store could not durably record or reload +// the decision. +func (w *confirmationRiskWatcher) durableDecisionTime(ctx context.Context, + decision ConfirmationRiskDecision) (time.Time, bool) { now := time.Now() if w.store == nil { - return now + return now, true } storedLoopIn, err := w.store.GetLoopInByHash(ctx, w.swapHash) @@ -141,11 +143,11 @@ func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, w.warnf("unable to reload persisted risk decision for swap %v: %v", w.swapHash, err) - return now + return time.Time{}, false } if storedLoopIn == nil { - return now + return time.Time{}, false } hasPersistedDecision := @@ -160,7 +162,7 @@ func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, w.warnf("unable to persist replayed risk decision for "+ "swap %v: %v", w.swapHash, err) - return now + return time.Time{}, false } storedLoopIn, err = w.store.GetLoopInByHash(ctx, w.swapHash) @@ -168,15 +170,27 @@ func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, w.warnf("unable to reload persisted risk decision for "+ "swap %v: %v", w.swapHash, err) - return now + return time.Time{}, false } if storedLoopIn == nil || storedLoopIn.ConfirmationRiskDecision != decision || storedLoopIn.ConfirmationRiskDecisionTime.IsZero() { - return now + return time.Time{}, false } } - return storedLoopIn.ConfirmationRiskDecisionTime + return storedLoopIn.ConfirmationRiskDecisionTime, true +} + +// decisionTime retains the best-effort behavior used for server notifications. +func (w *confirmationRiskWatcher) decisionTime(ctx context.Context, + decision ConfirmationRiskDecision) time.Time { + + decisionTime, ok := w.durableDecisionTime(ctx, decision) + if !ok { + return time.Now() + } + + return decisionTime } From 6c13d0782343b8205fe415df27273b05da832b65 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:55:03 +0200 Subject: [PATCH 20/35] cmd/loop: warn for low-confirmation static deposits Warn before dispatching a static loop-in that selects deposits below the conservative six-confirmation threshold. Mirror automatic coin selection before prompting so the warning reflects both manual and auto-selected deposits. Cover manual and auto-selected warning paths in CLI tests. --- cmd/loop/staticaddr.go | 189 ++++++++++++++++++++++++++++++- cmd/loop/staticaddr_test.go | 220 ++++++++++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 cmd/loop/staticaddr_test.go diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index f4f72d7fe..c973a0028 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,14 +4,17 @@ import ( "context" "errors" "fmt" + "sort" + "strings" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" - "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" ) @@ -553,11 +556,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { allDeposits := depositList.FilteredDeposits if len(allDeposits) == 0 { - errString := fmt.Sprintf("no confirmed deposits available, "+ - "deposits need at least %v confirmations", - deposit.MinConfs) - - return errors.New(errString) + return errors.New("no deposited outputs available") } var depositOutpoints []string @@ -614,6 +613,28 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { return err } + // Warn the user if any selected deposits have fewer than 6 + // confirmations, as the swap payment won't be received immediately + // for those. + summary, err := client.GetStaticAddressSummary( + ctx, &looprpc.StaticAddressSummaryRequest{}, + ) + if err != nil { + return err + } + + depositsToCheck := warningDepositOutpoints( + allDeposits, depositOutpoints, autoSelectDepositsForQuote, + quoteReq.Amt, + ) + warning := lowConfDepositWarning( + allDeposits, depositsToCheck, + int64(summary.RelativeExpiryBlocks), + ) + if warning != "" { + fmt.Println(warning) + } + if !(cmd.Bool("force") || cmd.Bool("f")) { err = displayInDetails(quoteReq, quote, cmd.Bool("verbose")) if err != nil { @@ -669,6 +690,162 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string { return outpoints } +var warningSelectionDustLimit = int64(lnwallet.DustLimitForSize(input.P2TRSize)) + +// warningDepositOutpoints returns the deposit outpoints to check for +// low-confirmation warnings. +func warningDepositOutpoints(allDeposits []*looprpc.Deposit, + selectedOutpoints []string, autoSelect bool, targetAmount int64) []string { + + if !autoSelect { + return selectedOutpoints + } + + return autoSelectedWarningOutpoints(allDeposits, targetAmount) +} + +// autoSelectedWarningOutpoints returns the outpoints selected by the same +// ordering used for automatic static loop-in deposit selection. +func autoSelectedWarningOutpoints(allDeposits []*looprpc.Deposit, + targetAmount int64) []string { + + if targetAmount <= 0 { + return nil + } + + // KEEP IN SYNC with staticaddr/loopin.SelectDeposits. + deposits := filterSwappableWarningDeposits(allDeposits) + sort.Slice(deposits, func(i, j int) bool { + iConfirmed := deposits[i].ConfirmationHeight > 0 + jConfirmed := deposits[j].ConfirmationHeight > 0 + if iConfirmed != jConfirmed { + return iConfirmed + } + + if deposits[i].Value == deposits[j].Value { + return deposits[i].BlocksUntilExpiry < + deposits[j].BlocksUntilExpiry + } + + return deposits[i].Value > deposits[j].Value + }) + + selectedOutpoints := make([]string, 0, len(deposits)) + var selectedAmount int64 + for _, deposit := range deposits { + selectedOutpoints = append(selectedOutpoints, deposit.Outpoint) + selectedAmount += deposit.Value + if selectedAmount == targetAmount { + return selectedOutpoints + } + + if selectedAmount > targetAmount && + selectedAmount-targetAmount >= warningSelectionDustLimit { + + return selectedOutpoints + } + } + + return nil +} + +// filterSwappableWarningDeposits filters deposits for CLI warning selection. +func filterSwappableWarningDeposits( + allDeposits []*looprpc.Deposit) []*looprpc.Deposit { + + swappable := make([]*looprpc.Deposit, 0, len(allDeposits)) + minBlocksUntilExpiry := int64( + loopin.DefaultLoopInOnChainCltvDelta + loopin.DepositHtlcDelta, + ) + for _, deposit := range allDeposits { + // Unconfirmed deposits remain swappable because their CSV timeout has + // not started yet. This mirrors loopin.IsSwappable. + if deposit.ConfirmationHeight > 0 && + deposit.BlocksUntilExpiry < minBlocksUntilExpiry { + + continue + } + + swappable = append(swappable, deposit) + } + + return swappable +} + +// conservativeWarningConfs is the highest default confirmation tier used by +// the server's dynamic confirmation-risk policy. +// +// The CLI does not currently know the server's exact policy, so we use this +// conservative threshold for warnings without promising immediate execution. +const conservativeWarningConfs = 6 + +// lowConfDepositWarning checks the selected deposits against a conservative +// confirmation threshold and returns a warning string if any are found. +func lowConfDepositWarning(allDeposits []*looprpc.Deposit, + selectedOutpoints []string, csvExpiry int64) string { + + depositMap := make(map[string]*looprpc.Deposit, len(allDeposits)) + for _, d := range allDeposits { + depositMap[d.Outpoint] = d + } + + var lowConfEntries []string + for _, op := range selectedOutpoints { + d, ok := depositMap[op] + if !ok { + continue + } + + var confs int64 + switch { + case d.ConfirmationHeight <= 0: + confs = 0 + + case csvExpiry > 0: + // For confirmed deposits we can compute + // confirmations as CSVExpiry - BlocksUntilExpiry + 1. + confs = csvExpiry - d.BlocksUntilExpiry + 1 + + default: + // Can't determine confirmations without the CSV expiry. + continue + } + + if confs >= conservativeWarningConfs { + continue + } + + if confs == 0 { + lowConfEntries = append( + lowConfEntries, + fmt.Sprintf(" - %s (unconfirmed)", op), + ) + } else { + lowConfEntries = append( + lowConfEntries, + fmt.Sprintf( + " - %s (%d confirmations)", op, + confs, + ), + ) + } + } + + if len(lowConfEntries) == 0 { + return "" + } + + return fmt.Sprintf( + "\nWARNING: The following deposits are below the "+ + "conservative %d-confirmation threshold:\n%s\n"+ + "The swap payment for these deposits may wait for "+ + "more confirmations depending on the server's "+ + "confirmation-risk policy.\n", + conservativeWarningConfs, + strings.Join(lowConfEntries, "\n"), + ) +} + func displayNewAddressWarning() error { fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " + ".loop under your home directory will take your ability to " + diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go new file mode 100644 index 000000000..2cc88ad66 --- /dev/null +++ b/cmd/loop/staticaddr_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "strings" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/stretchr/testify/require" +) + +// TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the +// conservative warning threshold are included in the warning text. +func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "confirmed-low", + ConfirmationHeight: 100, + BlocksUntilExpiry: 140, + }, + { + Outpoint: "confirmed-high", + ConfirmationHeight: 95, + BlocksUntilExpiry: 139, + }, + } + + warning := lowConfDepositWarning( + deposits, []string{"confirmed-low", "confirmed-high"}, 144, + ) + + require.Contains(t, warning, "confirmed-low (5 confirmations)") + require.NotContains(t, warning, "confirmed-high") +} + +// TestLowConfDepositWarningUnconfirmed verifies unconfirmed deposits get a +// warning that the swap may wait for confirmation-risk acceptance. +func TestLowConfDepositWarningUnconfirmed(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool", + ConfirmationHeight: 0, + BlocksUntilExpiry: 144, + }, + } + + warning := lowConfDepositWarning(deposits, []string{"mempool"}, 144) + + require.Contains(t, warning, "mempool (unconfirmed)") + require.True( + t, + strings.Contains( + warning, + "conservative 6-confirmation threshold", + ), + ) + require.NotContains(t, warning, "executed immediately") +} + +// TestWarningDepositOutpointsAutoSelectPrefersConfirmed verifies automatic +// warning selection keeps the loop-in preference for confirmed outputs. +func TestWarningDepositOutpointsAutoSelectPrefersConfirmed(t *testing.T) { + t.Parallel() + + const csvExpiry = 1100 + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool-large", + Value: 2_000_000, + ConfirmationHeight: 0, + BlocksUntilExpiry: csvExpiry, + }, + { + Outpoint: "confirmed", + Value: 1_500_000, + ConfirmationHeight: 100, + BlocksUntilExpiry: csvExpiry - 5, + }, + } + + selected := warningDepositOutpoints(deposits, nil, true, 1_000_000) + + require.Equal(t, []string{"confirmed"}, selected) + require.Empty(t, lowConfDepositWarning(deposits, selected, csvExpiry)) +} + +// TestWarningDepositOutpointsAutoSelectIncludesNeededUnconfirmed verifies the +// warning path includes mempool deposits when they are needed for the target. +func TestWarningDepositOutpointsAutoSelectIncludesNeededUnconfirmed(t *testing.T) { + t.Parallel() + + const csvExpiry = 1100 + + deposits := []*looprpc.Deposit{ + { + Outpoint: "confirmed-small", + Value: 500_000, + ConfirmationHeight: 100, + BlocksUntilExpiry: csvExpiry - 5, + }, + { + Outpoint: "mempool-large", + Value: 2_000_000, + ConfirmationHeight: 0, + BlocksUntilExpiry: csvExpiry, + }, + } + + selected := warningDepositOutpoints(deposits, nil, true, 1_000_000) + + require.Equal( + t, []string{"confirmed-small", "mempool-large"}, selected, + ) + + warning := lowConfDepositWarning(deposits, selected, csvExpiry) + require.Contains(t, warning, "mempool-large (unconfirmed)") + require.NotContains(t, warning, "confirmed-small") +} + +// TestWarningDepositSelectionMatchesLoopInSelection verifies CLI warning +// selection matches the loop-in selector. +func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { + t.Parallel() + + const ( + blockHeight = uint32(10_000) + csvExpiry = uint32(1_200) + targetAmount = int64(2_500_000) + ) + + type fixture struct { + name string + value int64 + confirmationHeight int64 + } + + fixtures := []fixture{ + { + name: "mempool-huge", + value: 3_000_000, + confirmationHeight: 0, + }, + { + name: "confirmed-later-expiry", + value: 2_000_000, + confirmationHeight: 9_900, + }, + { + name: "confirmed-earlier-expiry", + value: 2_000_000, + confirmationHeight: 9_890, + }, + { + name: "confirmed-small", + value: 600_000, + confirmationHeight: 9_900, + }, + { + name: "confirmed-too-close-to-expiry", + value: 5_000_000, + confirmationHeight: 9_849, + }, + } + + rpcDeposits := make([]*looprpc.Deposit, 0, len(fixtures)) + loopInDeposits := make([]*deposit.Deposit, 0, len(fixtures)) + for idx, fixture := range fixtures { + hash := chainhash.Hash{byte(idx + 1)} + outpoint := wire.OutPoint{ + Hash: hash, + Index: uint32(idx), + } + + blocksUntilExpiry := int64(0) + if fixture.confirmationHeight > 0 { + blocksUntilExpiry = fixture.confirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + rpcDeposits = append(rpcDeposits, &looprpc.Deposit{ + Outpoint: outpoint.String(), + Value: fixture.value, + ConfirmationHeight: fixture.confirmationHeight, + BlocksUntilExpiry: blocksUntilExpiry, + }) + loopInDeposits = append(loopInDeposits, &deposit.Deposit{ + OutPoint: outpoint, + Value: btcutil.Amount(fixture.value), + ConfirmationHeight: fixture.confirmationHeight, + }) + } + + cliSelected := autoSelectedWarningOutpoints( + rpcDeposits, targetAmount, + ) + + loopInSelected, err := loopin.SelectDeposits( + btcutil.Amount(targetAmount), loopInDeposits, csvExpiry, + blockHeight, + ) + require.NoError(t, err) + + loopInSelectedOutpoints := make([]string, 0, len(loopInSelected)) + for _, selected := range loopInSelected { + loopInSelectedOutpoints = append( + loopInSelectedOutpoints, selected.OutPoint.String(), + ) + } + + require.Equal(t, loopInSelectedOutpoints, cliSelected) +} From 5b29bb10b22c4fba4ef0ebba744941f14f9c9de2 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:56:10 +0200 Subject: [PATCH 21/35] cmd/loop: update static loop-in replay fixtures Refresh static loop-in replay sessions for the low-confirmation warning and payment-timeout prompts. Add replay coverage for the warning prompt and update fee and payment-timeout variants for the new interaction sequence. --- .../static-loop-in/15_loop-static-in.json | 31 +++++++++++++++++++ ...-static-in-positional-payment-timeout.json | 31 +++++++++++++++++++ .../19_loop-static-in-all-cancel.json | 31 +++++++++++++++++++ .../23_loop-static-in-max-swap-fee-both.json | 31 +++++++++++++++++++ ...op-static-in-max-swap-fee-sat-success.json | 31 +++++++++++++++++++ 5 files changed, 155 insertions(+) diff --git a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json index ea600c31e..5acb8ddf0 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json +++ b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json @@ -91,6 +91,37 @@ } } }, + { + "time_ms": 65, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 65, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw", + "relative_expiry_blocks": "14400", + "total_num_deposits": 1, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "2500000", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, { "time_ms": 65, "kind": "stdout", diff --git a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json index 5a6b7c1cf..b997322b0 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json +++ b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json @@ -94,6 +94,37 @@ } } }, + { + "time_ms": 500, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 500, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw", + "relative_expiry_blocks": "14400", + "total_num_deposits": 1, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "500000", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, { "time_ms": 500, "kind": "grpc", diff --git a/cmd/loop/testdata/sessions/static-loop-in/19_loop-static-in-all-cancel.json b/cmd/loop/testdata/sessions/static-loop-in/19_loop-static-in-all-cancel.json index 073bcb3ba..d562f2ef3 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/19_loop-static-in-all-cancel.json +++ b/cmd/loop/testdata/sessions/static-loop-in/19_loop-static-in-all-cancel.json @@ -99,6 +99,37 @@ } } }, + { + "time_ms": 446, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 446, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw", + "relative_expiry_blocks": "14400", + "total_num_deposits": 1, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "500000", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, { "time_ms": 446, "kind": "stdout", diff --git a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json index 329b8e5b0..02b5a3ac4 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json +++ b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json @@ -127,6 +127,37 @@ } } }, + { + "time_ms": 50, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 50, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1p604kzzh28764kkw45yps48weergwljggamhhe7tqfglzjzang6cs43f2m2", + "relative_expiry_blocks": "14400", + "total_num_deposits": 4, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "2546150", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, { "time_ms": 50, "kind": "grpc", diff --git a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json index e108e966b..f2994fde2 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json +++ b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json @@ -118,6 +118,37 @@ } } }, + { + "time_ms": 45, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 45, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1p604kzzh28764kkw45yps48weergwljggamhhe7tqfglzjzang6cs43f2m2", + "relative_expiry_blocks": "14400", + "total_num_deposits": 3, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "2046150", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, { "time_ms": 45, "kind": "grpc", From 3a0e8f83665745778105f372dc1a412c4e406c5e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:55:44 +0200 Subject: [PATCH 22/35] swap: reserve multi-address key families Reserve separate key families for static receive and change addresses. This keeps derived keys out of the legacy static-address and HTLC key streams. --- swap/keychain.go | 13 +++++++++++-- swap/keychain_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 swap/keychain_test.go diff --git a/swap/keychain.go b/swap/keychain.go index 37106950c..eded48133 100644 --- a/swap/keychain.go +++ b/swap/keychain.go @@ -5,7 +5,16 @@ var ( // spending of the htlc. KeyFamily = int32(99) - // StaticAddressKeyFamily is the key family used to generate static - // address keys. + // StaticAddressKeyFamily is the legacy static-address key family. It is + // used for the V0 single static-address key and for static-address HTLC + // keys. StaticAddressKeyFamily = int32(42060) + + // StaticMultiAddressKeyFamily is the key family used to generate + // externally visible multi-address static-address receive keys. + StaticMultiAddressKeyFamily = int32(42061) + + // StaticAddressChangeKeyFamily is the key family used to generate + // static-address change outputs. + StaticAddressChangeKeyFamily = int32(42062) ) diff --git a/swap/keychain_test.go b/swap/keychain_test.go new file mode 100644 index 000000000..d45a38940 --- /dev/null +++ b/swap/keychain_test.go @@ -0,0 +1,24 @@ +package swap + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStaticAddressKeyFamiliesAreDisjoint documents the key-family split used +// by static-address HTLC, receive and change key derivation. +func TestStaticAddressKeyFamiliesAreDisjoint(t *testing.T) { + families := map[int32]string{ + KeyFamily: "swap htlc", + StaticAddressKeyFamily: "legacy static address and htlc", + StaticMultiAddressKeyFamily: "multi-address receive", + StaticAddressChangeKeyFamily: "static-address change", + } + + require.Len(t, families, 4) + require.EqualValues(t, 99, KeyFamily) + require.EqualValues(t, 42060, StaticAddressKeyFamily) + require.EqualValues(t, 42061, StaticMultiAddressKeyFamily) + require.EqualValues(t, 42062, StaticAddressChangeKeyFamily) +} From 4117eaf60f02138901448b902a9811290efdd533 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 11:58:03 +0200 Subject: [PATCH 23/35] loopdb: persist deposit address ownership Associate every deposit with the static address parameters that created it. This lets restored deposits recover the correct script and signing keys instead of assuming the legacy root address. --- .../000022_deposit_static_address_id.down.sql | 1 + .../000022_deposit_static_address_id.up.sql | 8 + loopdb/sqlc/models.go | 1 + loopdb/sqlc/querier.go | 9 +- .../sqlc/queries/static_address_deposits.sql | 54 +++++- loopdb/sqlc/queries/static_addresses.sql | 14 +- loopdb/sqlc/static_address_deposits.sql.go | 167 ++++++++++++++-- loopdb/sqlc/static_address_loopin.sql.go | 4 +- loopdb/sqlc/static_addresses.sql.go | 36 ++++ staticaddr/address/sql_store.go | 22 ++- staticaddr/deposit/deposit.go | 20 ++ staticaddr/deposit/sql_store.go | 181 +++++++++++++++++- staticaddr/deposit/sql_store_test.go | 18 +- staticaddr/loopin/sql_store.go | 2 +- staticaddr/script/parameters.go | 4 + 15 files changed, 498 insertions(+), 43 deletions(-) create mode 100644 loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql create mode 100644 loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql new file mode 100644 index 000000000..e112a7b1f --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.down.sql @@ -0,0 +1 @@ +ALTER TABLE deposits DROP COLUMN static_address_id; diff --git a/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql new file mode 100644 index 000000000..4246b116e --- /dev/null +++ b/loopdb/sqlc/migrations/000022_deposit_static_address_id.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE deposits ADD static_address_id INT REFERENCES static_addresses(id); + +UPDATE deposits +SET static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 78a75d042..34a924256 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -20,6 +20,7 @@ type Deposit struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 } type DepositUpdate struct { diff --git a/loopdb/sqlc/querier.go b/loopdb/sqlc/querier.go index ba3c35eb9..1eed8060c 100644 --- a/loopdb/sqlc/querier.go +++ b/loopdb/sqlc/querier.go @@ -10,7 +10,7 @@ import ( ) type Querier interface { - AllDeposits(ctx context.Context) ([]Deposit, error) + AllDeposits(ctx context.Context) ([]AllDepositsRow, error) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) CancelBatch(ctx context.Context, id int32) error CreateDeposit(ctx context.Context, arg CreateDepositParams) error @@ -18,19 +18,20 @@ type Querier interface { CreateStaticAddress(ctx context.Context, arg CreateStaticAddressParams) error CreateWithdrawal(ctx context.Context, arg CreateWithdrawalParams) error CreateWithdrawalDeposit(ctx context.Context, arg CreateWithdrawalDepositParams) error - DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) + DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([][]byte, error) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]DepositsForSwapHashRow, error) FetchLiquidityParams(ctx context.Context) ([]byte, error) GetAllWithdrawals(ctx context.Context) ([]Withdrawal, error) GetBatchSweeps(ctx context.Context, batchID int32) ([]Sweep, error) GetBatchSweptAmount(ctx context.Context, batchID int32) (int64, error) - GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) + GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) GetInstantOutSwap(ctx context.Context, swapHash []byte) (GetInstantOutSwapRow, error) GetInstantOutSwapUpdates(ctx context.Context, swapHash []byte) ([]InstantoutUpdate, error) GetInstantOutSwaps(ctx context.Context) ([]GetInstantOutSwapsRow, error) GetLastUpdateID(ctx context.Context, swapHash []byte) (int32, error) GetLatestDepositUpdate(ctx context.Context, depositID []byte) (DepositUpdate, error) + GetLegacyAddress(ctx context.Context) (StaticAddress, error) GetLoopInSwap(ctx context.Context, swapHash []byte) (GetLoopInSwapRow, error) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]StaticAddressSwapUpdate, error) GetLoopInSwaps(ctx context.Context) ([]GetLoopInSwapsRow, error) @@ -42,6 +43,7 @@ type Querier interface { GetReservationUpdates(ctx context.Context, reservationID []byte) ([]ReservationUpdate, error) GetReservations(ctx context.Context) ([]Reservation, error) GetStaticAddress(ctx context.Context, pkscript []byte) (StaticAddress, error) + GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) GetSwapUpdates(ctx context.Context, swapHash []byte) ([]SwapUpdate, error) @@ -68,6 +70,7 @@ type Querier interface { OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error + SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error) UpdateBatch(ctx context.Context, arg UpdateBatchParams) error UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error diff --git a/loopdb/sqlc/queries/static_address_deposits.sql b/loopdb/sqlc/queries/static_address_deposits.sql index 2987e469e..e9b912fef 100644 --- a/loopdb/sqlc/queries/static_address_deposits.sql +++ b/loopdb/sqlc/queries/static_address_deposits.sql @@ -7,7 +7,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -16,7 +17,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ); -- name: UpdateDeposit :exec @@ -43,17 +45,35 @@ INSERT INTO deposit_updates ( -- name: GetDeposit :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1; -- name: DepositForOutpoint :one SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -61,11 +81,20 @@ AND -- name: AllDeposits :many SELECT - * + d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC; + d.id ASC; -- name: GetLatestDepositUpdate :one SELECT @@ -76,4 +105,9 @@ WHERE deposit_id = $1 ORDER BY update_timestamp DESC -LIMIT 1; \ No newline at end of file +LIMIT 1; + +-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL; diff --git a/loopdb/sqlc/queries/static_addresses.sql b/loopdb/sqlc/queries/static_addresses.sql index c613cfd93..cc86fa7e2 100644 --- a/loopdb/sqlc/queries/static_addresses.sql +++ b/loopdb/sqlc/queries/static_addresses.sql @@ -1,10 +1,15 @@ -- name: AllStaticAddresses :many -SELECT * FROM static_addresses; +SELECT * FROM static_addresses +ORDER BY id ASC; -- name: GetStaticAddress :one SELECT * FROM static_addresses WHERE pkscript=$1; +-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1; + -- name: CreateStaticAddress :exec INSERT INTO static_addresses ( client_pubkey, @@ -24,4 +29,9 @@ INSERT INTO static_addresses ( $6, $7, $8 - ); \ No newline at end of file + ); + +-- name: GetLegacyAddress :one +SELECT * FROM static_addresses +ORDER BY id ASC +LIMIT 1; diff --git a/loopdb/sqlc/static_address_deposits.sql.go b/loopdb/sqlc/static_address_deposits.sql.go index 191f1f563..ed23f4209 100644 --- a/loopdb/sqlc/static_address_deposits.sql.go +++ b/loopdb/sqlc/static_address_deposits.sql.go @@ -13,22 +13,53 @@ import ( const allDeposits = `-- name: AllDeposits :many SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id ORDER BY - id ASC + d.id ASC ` -func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { +type AllDepositsRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) AllDeposits(ctx context.Context) ([]AllDepositsRow, error) { rows, err := q.db.QueryContext(ctx, allDeposits) if err != nil { return nil, err } defer rows.Close() - var items []Deposit + var items []AllDepositsRow for rows.Next() { - var i Deposit + var i AllDepositsRow if err := rows.Scan( &i.ID, &i.DepositID, @@ -40,6 +71,15 @@ func (q *Queries) AllDeposits(ctx context.Context) ([]Deposit, error) { &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ); err != nil { return nil, err } @@ -63,7 +103,8 @@ INSERT INTO deposits ( confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, - finalized_withdrawal_tx + finalized_withdrawal_tx, + static_address_id ) VALUES ( $1, $2, @@ -72,7 +113,8 @@ INSERT INTO deposits ( $5, $6, $7, - $8 + $8, + $9 ) ` @@ -85,6 +127,7 @@ type CreateDepositParams struct { TimeoutSweepPkScript []byte ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString + StaticAddressID sql.NullInt32 } func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) error { @@ -97,15 +140,25 @@ func (q *Queries) CreateDeposit(ctx context.Context, arg CreateDepositParams) er arg.TimeoutSweepPkScript, arg.ExpirySweepTxid, arg.FinalizedWithdrawalTx, + arg.StaticAddressID, ) return err } const depositForOutpoint = `-- name: DepositForOutpoint :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE tx_hash = $1 AND @@ -117,9 +170,31 @@ type DepositForOutpointParams struct { OutIndex int32 } -func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (Deposit, error) { +type DepositForOutpointRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpointParams) (DepositForOutpointRow, error) { row := q.db.QueryRowContext(ctx, depositForOutpoint, arg.TxHash, arg.OutIndex) - var i Deposit + var i DepositForOutpointRow err := row.Scan( &i.ID, &i.DepositID, @@ -131,22 +206,62 @@ func (q *Queries) DepositForOutpoint(ctx context.Context, arg DepositForOutpoint &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } const getDeposit = `-- name: GetDeposit :one SELECT - id, deposit_id, tx_hash, out_index, amount, confirmation_height, timeout_sweep_pk_script, expiry_sweep_txid, finalized_withdrawal_tx, swap_hash + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height FROM - deposits + deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id WHERE deposit_id = $1 ` -func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, error) { +type GetDepositRow struct { + ID int32 + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (GetDepositRow, error) { row := q.db.QueryRowContext(ctx, getDeposit, depositID) - var i Deposit + var i GetDepositRow err := row.Scan( &i.ID, &i.DepositID, @@ -158,6 +273,15 @@ func (q *Queries) GetDeposit(ctx context.Context, depositID []byte) (Deposit, er &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, ) return i, err } @@ -209,6 +333,17 @@ func (q *Queries) InsertDepositUpdate(ctx context.Context, arg InsertDepositUpda return err } +const setAllNullDepositsStaticAddressID = `-- name: SetAllNullDepositsStaticAddressID :exec +UPDATE deposits +SET static_address_id = $1 +WHERE static_address_id IS NULL +` + +func (q *Queries) SetAllNullDepositsStaticAddressID(ctx context.Context, staticAddressID sql.NullInt32) error { + _, err := q.db.ExecContext(ctx, setAllNullDepositsStaticAddressID, staticAddressID) + return err +} + const updateDeposit = `-- name: UpdateDeposit :exec UPDATE deposits SET diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 319340168..f6c896ce6 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -45,7 +45,7 @@ func (q *Queries) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([ const depositsForSwapHash = `-- name: DepositsForSwapHash :many SELECT - d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, + d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, u.update_state, u.update_timestamp FROM @@ -73,6 +73,7 @@ type DepositsForSwapHashRow struct { ExpirySweepTxid []byte FinalizedWithdrawalTx sql.NullString SwapHash []byte + StaticAddressID sql.NullInt32 UpdateState sql.NullString UpdateTimestamp sql.NullTime } @@ -97,6 +98,7 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &i.ExpirySweepTxid, &i.FinalizedWithdrawalTx, &i.SwapHash, + &i.StaticAddressID, &i.UpdateState, &i.UpdateTimestamp, ); err != nil { diff --git a/loopdb/sqlc/static_addresses.sql.go b/loopdb/sqlc/static_addresses.sql.go index 054c07364..dbdb0e271 100644 --- a/loopdb/sqlc/static_addresses.sql.go +++ b/loopdb/sqlc/static_addresses.sql.go @@ -11,6 +11,7 @@ import ( const allStaticAddresses = `-- name: AllStaticAddresses :many SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC ` func (q *Queries) AllStaticAddresses(ctx context.Context) ([]StaticAddress, error) { @@ -93,6 +94,29 @@ func (q *Queries) CreateStaticAddress(ctx context.Context, arg CreateStaticAddre return err } +const getLegacyAddress = `-- name: GetLegacyAddress :one +SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses +ORDER BY id ASC +LIMIT 1 +` + +func (q *Queries) GetLegacyAddress(ctx context.Context) (StaticAddress, error) { + row := q.db.QueryRowContext(ctx, getLegacyAddress) + var i StaticAddress + err := row.Scan( + &i.ID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, + ) + return i, err +} + const getStaticAddress = `-- name: GetStaticAddress :one SELECT id, client_pubkey, server_pubkey, expiry, client_key_family, client_key_index, pkscript, protocol_version, initiation_height FROM static_addresses WHERE pkscript=$1 @@ -114,3 +138,15 @@ func (q *Queries) GetStaticAddress(ctx context.Context, pkscript []byte) (Static ) return i, err } + +const getStaticAddressID = `-- name: GetStaticAddressID :one +SELECT id FROM static_addresses +WHERE pkscript=$1 +` + +func (q *Queries) GetStaticAddressID(ctx context.Context, pkscript []byte) (int32, error) { + row := q.db.QueryRowContext(ctx, getStaticAddressID, pkscript) + var id int32 + err := row.Scan(&id) + return id, err +} diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 43257b81d..16f113c44 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -42,7 +42,14 @@ func (s *SqlStore) CreateStaticAddress(ctx context.Context, return s.baseDB.Queries.CreateStaticAddress(ctx, createArgs) } -// GetAllStaticAddresses returns all address known to the server. +// GetStaticAddressID retrieves the database ID for a static address script. +func (s *SqlStore) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return s.baseDB.Queries.GetStaticAddressID(ctx, pkScript) +} + +// GetAllStaticAddresses returns all addresses known to the client. func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( []*script.Parameters, error) { @@ -64,6 +71,18 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( return result, nil } +// GetLegacyParameters returns the first static address created for this L402. +func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( + *script.Parameters, error) { + + staticAddress, err := s.baseDB.Queries.GetLegacyAddress(ctx) + if err != nil { + return nil, err + } + + return s.toAddressParameters(staticAddress) +} + // toAddressParameters transforms a database representation of a static address // to an AddressParameters struct. func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( @@ -80,6 +99,7 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( } return &script.Parameters{ + ID: row.ID, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, PkScript: row.Pkscript, diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index d63cc4b74..8d5fa4636 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -9,6 +9,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" ) @@ -70,6 +72,11 @@ type Deposit struct { // FinalizedWithdrawalTx is the coop-signed withdrawal transaction. It // is republished on new block arrivals and on client restarts. FinalizedWithdrawalTx *wire.MsgTx + + // AddressParams are the static address parameters that produced this + // deposit's pkScript. Spending code must use these per-deposit + // parameters rather than assuming all deposits belong to one address. + AddressParams *script.Parameters } // IsInFinalState returns true if the deposit is final. @@ -152,6 +159,19 @@ func (d *Deposit) GetConfirmationHeightNoLock() int64 { return d.ConfirmationHeight } +// GetStaticAddressScript reconstructs the static address script for this +// deposit's matched address parameters. +func (d *Deposit) GetStaticAddressScript() (*script.StaticAddress, error) { + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(d.AddressParams.Expiry), + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + ) +} + // GetRandomDepositID generates a random deposit ID. func GetRandomDepositID() (ID, error) { var id ID diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index a49550e5c..dcacded57 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -6,14 +6,19 @@ import ( "database/sql" "encoding/hex" "errors" + "fmt" + "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/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" ) @@ -49,6 +54,17 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { Amount: int64(deposit.Value), ConfirmationHeight: deposit.GetConfirmationHeight(), TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, + StaticAddressID: sql.NullInt32{}, + } + if deposit.AddressParams != nil { + if deposit.AddressParams.ID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + createArgs.StaticAddressID = sql.NullInt32{ + Int32: deposit.AddressParams.ID, + Valid: true, + } } updateArgs := sqlc.InsertDepositUpdateParams{ @@ -147,7 +163,9 @@ func (s *SqlStore) GetDeposit(ctx context.Context, id ID) (*Deposit, error) { return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromGet(row), latestUpdate, + ) if err != nil { return err } @@ -193,7 +211,9 @@ func (s *SqlStore) DepositForOutpoint(ctx context.Context, return err } - deposit, err = ToDeposit(row, latestUpdate) + deposit, err = toDeposit( + depositRowFromOutpoint(row), latestUpdate, + ) if err != nil { return err } @@ -245,8 +265,105 @@ func (s *SqlStore) AllDeposits(ctx context.Context) ([]*Deposit, error) { return allDeposits, nil } -// ToDeposit converts an sql deposit to a deposit. -func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, +// ToDeposit converts an sql deposit row with joined static address metadata to +// a deposit. +func ToDeposit(row sqlc.AllDepositsRow, lastUpdate sqlc.DepositUpdate) (*Deposit, + error) { + + return toDeposit(depositRowFromAll(row), lastUpdate) +} + +type depositRow struct { + DepositID []byte + TxHash []byte + OutIndex int32 + Amount int64 + ConfirmationHeight int64 + TimeoutSweepPkScript []byte + ExpirySweepTxid []byte + FinalizedWithdrawalTx sql.NullString + SwapHash []byte + StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 +} + +func depositRowFromAll(row sqlc.AllDepositsRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromGet(row sqlc.GetDepositRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func depositRowFromOutpoint(row sqlc.DepositForOutpointRow) depositRow { + return depositRow{ + DepositID: row.DepositID, + TxHash: row.TxHash, + OutIndex: row.OutIndex, + Amount: row.Amount, + ConfirmationHeight: row.ConfirmationHeight, + TimeoutSweepPkScript: row.TimeoutSweepPkScript, + ExpirySweepTxid: row.ExpirySweepTxid, + FinalizedWithdrawalTx: row.FinalizedWithdrawalTx, + SwapHash: row.SwapHash, + StaticAddressID: row.StaticAddressID, + ClientPubkey: row.ClientPubkey, + ServerPubkey: row.ServerPubkey, + Expiry: row.Expiry, + ClientKeyFamily: row.ClientKeyFamily, + ClientKeyIndex: row.ClientKeyIndex, + Pkscript: row.Pkscript, + ProtocolVersion: row.ProtocolVersion, + InitiationHeight: row.InitiationHeight, + } +} + +func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, error) { id := ID{} @@ -296,7 +413,7 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, swapHash = &hash } - return &Deposit{ + deposit := &Deposit{ ID: id, state: fsm.StateType(lastUpdate.UpdateState), OutPoint: wire.OutPoint{ @@ -309,5 +426,57 @@ func ToDeposit(row sqlc.Deposit, lastUpdate sqlc.DepositUpdate) (*Deposit, ExpirySweepTxid: expirySweepTxid, SwapHash: swapHash, FinalizedWithdrawalTx: finalizedWithdrawalTx, - }, nil + } + + if row.StaticAddressID.Valid { + clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, err + } + + serverPubkey, err := btcec.ParsePubKey(row.ServerPubkey) + if err != nil { + return nil, err + } + + deposit.AddressParams = &script.Parameters{ + ID: row.StaticAddressID.Int32, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: uint32(row.Expiry.Int32), + PkScript: row.Pkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ClientKeyFamily.Int32, + ), + Index: uint32(row.ClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ProtocolVersion.Int32, + ), + InitiationHeight: row.InitiationHeight.Int32, + } + } + + return deposit, nil +} + +// BatchSetStaticAddressID sets the static address id for all deposits that +// predate the deposit-to-address schema link. +func (s *SqlStore) BatchSetStaticAddressID(ctx context.Context, + staticAddressID int32) error { + + if staticAddressID <= 0 { + return fmt.Errorf("static address ID must be set") + } + + return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(), + func(q *sqlc.Queries) error { + return q.SetAllNullDepositsStaticAddressID( + ctx, sql.NullInt32{ + Int32: staticAddressID, + Valid: true, + }, + ) + }) } diff --git a/staticaddr/deposit/sql_store_test.go b/staticaddr/deposit/sql_store_test.go index 5656e386a..4045b2811 100644 --- a/staticaddr/deposit/sql_store_test.go +++ b/staticaddr/deposit/sql_store_test.go @@ -1,6 +1,7 @@ package deposit import ( + "context" "database/sql" "testing" @@ -8,10 +9,21 @@ import ( "github.com/jackc/pgx/v5" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) +func TestCreateDepositRejectsUnpersistedAddress(t *testing.T) { + store := NewSqlStore(nil) + deposit := &Deposit{ + AddressParams: &script.Parameters{}, + } + + err := store.CreateDeposit(context.Background(), deposit) + require.ErrorContains(t, err, "static address ID must be set") +} + func TestToDeposit(t *testing.T) { depositID, err := GetRandomDepositID() require.NoError(t, err) @@ -24,13 +36,13 @@ func TestToDeposit(t *testing.T) { tests := []struct { name string - row sqlc.Deposit + row sqlc.AllDepositsRow lastUpdate sqlc.DepositUpdate expectErr bool }{ { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, @@ -44,7 +56,7 @@ func TestToDeposit(t *testing.T) { }, { name: "fully valid data", - row: sqlc.Deposit{ + row: sqlc.AllDepositsRow{ DepositID: depositID[:], TxHash: txHash[:], Amount: 100000000, diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 8b36dbe4a..e5af47ca2 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -578,7 +578,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, return nil, err } - sqlcDeposit := sqlc.Deposit{ + sqlcDeposit := sqlc.AllDepositsRow{ DepositID: id[:], TxHash: d.TxHash, Amount: d.Amount, diff --git a/staticaddr/script/parameters.go b/staticaddr/script/parameters.go index 89e2470b6..0fa1f73b4 100644 --- a/staticaddr/script/parameters.go +++ b/staticaddr/script/parameters.go @@ -9,6 +9,10 @@ import ( // Parameters holds all the necessary information for the 2-of-2 multisig // address. type Parameters struct { + // ID is the database primary key of the static address row. A zero value + // means the parameters have not been persisted yet. + ID int32 + // ClientPubkey is the client's pubkey for the static address. It is // used for the 2-of-2 funding output as well as for the client's // timeout path. From 543f916a100e410fd87a539193919c7c33443401 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:05 +0200 Subject: [PATCH 24/35] staticaddr/address: activate derived addresses Create receive and change addresses from locally derived client keys while reusing the server key and expiry from the legacy seed. Import, persist, and activate each script before returning it to callers. --- loopd/swapclient_server.go | 16 +- loopd/swapclient_server_staticaddr_test.go | 38 +- loopd/swapclient_server_test.go | 37 +- staticaddr/address/interface.go | 19 +- staticaddr/address/manager.go | 407 ++++++++++++++++----- staticaddr/address/manager_test.go | 18 +- staticaddr/address/sql_store.go | 15 +- 7 files changed, 430 insertions(+), 120 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d89dbbce2..d8d98a6b8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1731,7 +1731,7 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, // List all unspent utxos the wallet sees, regardless of the number of // confirmations. - staticAddress, utxos, err := s.staticAddressManager.ListUnspentRaw( + utxos, err := s.staticAddressManager.ListUnspentRaw( ctx, req.MinConfs, req.MaxConfs, ) if err != nil { @@ -1782,6 +1782,20 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, continue } + params := s.staticAddressManager.GetParameters(u.PkScript) + if params == nil { + return nil, fmt.Errorf("missing static address "+ + "parameters for %v", u.OutPoint) + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + if err != nil { + return nil, err + } + utxo := &looprpc.Utxo{ StaticAddress: staticAddress.String(), AmountSat: int64(u.Value), diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb4cc01ca..9478109bb 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -62,12 +62,44 @@ func (s *staticAddrDepositStore) AllDeposits(context.Context) ( return s.allDeposits, nil } -type staticAddrTestAddressManager struct{} +type staticAddrTestAddressManager struct { + params *address.Parameters +} + +func newStaticAddrTestAddressManager() *staticAddrTestAddressManager { + _, client := mock_lnd.CreateKey(1) + _, server := mock_lnd.CreateKey(2) + + return &staticAddrTestAddressManager{ + params: &address.Parameters{ + ID: 1, + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: []byte("pkscript"), + }, + } +} func (s *staticAddrTestAddressManager) GetStaticAddressParameters( context.Context) (*script.Parameters, error) { - return nil, nil + return s.params, nil +} + +func (s *staticAddrTestAddressManager) GetStaticAddressID( + context.Context, []byte) (int32, error) { + + return s.params.ID, nil +} + +func (s *staticAddrTestAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + params := *s.params + params.PkScript = pkScript + + return ¶ms } func (s *staticAddrTestAddressManager) GetStaticAddress( @@ -99,7 +131,7 @@ func newTestDepositManager( } return deposit.NewManager(&deposit.ManagerConfig{ - AddressManager: &staticAddrTestAddressManager{}, + AddressManager: newStaticAddrTestAddressManager(), Store: &staticAddrDepositStore{ allDeposits: deposits, byOutpoint: byOutpoint, diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f3fab0328..69fbcfdd7 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1,7 +1,9 @@ package loopd import ( + "bytes" "context" + "database/sql" "fmt" "os" "testing" @@ -25,6 +27,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" @@ -1281,10 +1284,25 @@ type mockAddressStore struct { func (s *mockAddressStore) CreateStaticAddress(_ context.Context, p *script.Parameters) error { + if p.ID == 0 { + p.ID = int32(len(s.params) + 1) + } s.params = append(s.params, p) return nil } +func (s *mockAddressStore) GetStaticAddressID(_ context.Context, + pkScript []byte) (int32, error) { + + for _, p := range s.params { + if bytes.Equal(p.PkScript, pkScript) { + return p.ID, nil + } + } + + return 0, sql.ErrNoRows +} + func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) ( *script.Parameters, error) { @@ -1301,6 +1319,16 @@ func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) ( return s.params, nil } +func (s *mockAddressStore) GetLegacyParameters(_ context.Context) ( + *address.Parameters, error) { + + if len(s.params) == 0 { + return nil, sql.ErrNoRows + } + + return s.params[0], nil +} + // mockDepositStore implements deposit.Store minimally for DepositsForOutpoints. type mockDepositStore struct { byOutpoint map[string]*deposit.Deposit @@ -1436,7 +1464,12 @@ func TestListUnspentDeposits(t *testing.T) { // Prepare a single static address parameter set. _, client := mock_lnd.CreateKey(1) _, server := mock_lnd.CreateKey(2) - pkScript := []byte("pkscript") + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, 10, client, server, + ) + require.NoError(t, err) + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) addrParams := &script.Parameters{ ClientPubkey: client, ServerPubkey: server, @@ -1454,6 +1487,8 @@ func TestListUnspentDeposits(t *testing.T) { // ChainNotifier and AddressClient are not needed for this test. }, 1) require.NoError(t, err) + _, err = addrMgr.EnsureStaticAddressSeed(ctx) + require.NoError(t, err) // Construct several UTXOs with different confirmation counts. makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo { diff --git a/staticaddr/address/interface.go b/staticaddr/address/interface.go index 63b6cf7c1..8a9805a6b 100644 --- a/staticaddr/address/interface.go +++ b/staticaddr/address/interface.go @@ -6,15 +6,26 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" ) +// Parameters aliases the script-level static address parameters for callers +// that interact with the address manager API. +type Parameters = script.Parameters + // Store is the database interface that is used to store and retrieve // static addresses. type Store interface { // CreateStaticAddress inserts a new static address with its parameters // into the store. - CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error + CreateStaticAddress(ctx context.Context, addrParams *Parameters) error + + // GetStaticAddressID retrieves the static address row ID for the + // address script. + GetStaticAddressID(ctx context.Context, pkScript []byte) (int32, error) // GetAllStaticAddresses retrieves all static addresses from the store. - GetAllStaticAddresses(ctx context.Context) ([]*script.Parameters, - error) + GetAllStaticAddresses(ctx context.Context) ([]*Parameters, error) + + // GetLegacyParameters retrieves the first static address created for the + // L402. This is the immutable legacy/root address that anchors existing + // single-address deposits. + GetLegacyParameters(ctx context.Context) (*Parameters, error) } diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 322eed739..b9c5dc3b0 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -1,9 +1,11 @@ package address import ( - "bytes" "context" + "database/sql" + "errors" "fmt" + "strings" "sync" "sync/atomic" @@ -29,6 +31,12 @@ const ( maxStaticAddressCSVExpiry = uint32(200 * 144) ) +var ( + // ErrNoStaticAddress is returned when no static address parameters are + // present in the store. + ErrNoStaticAddress = errors.New("no static address parameters found") +) + // ManagerConfig holds the configuration for the address manager. type ManagerConfig struct { // AddressClient is the client that communicates with the loop server @@ -62,6 +70,12 @@ type Manager struct { cfg *ManagerConfig currentHeight atomic.Int32 + + // activeStaticAddresses is the runtime index used to match wallet UTXOs + // to locally known static address parameters. The DB remains the + // durable source of truth; this map is rebuilt from the DB on startup + // and updated after successful address issuance. + activeStaticAddresses map[string]*Parameters } // NewManager creates a new address manager. @@ -72,7 +86,8 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { } m := &Manager{ - cfg: cfg, + cfg: cfg, + activeStaticAddresses: make(map[string]*Parameters), } m.currentHeight.Store(currentHeight) @@ -88,6 +103,11 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + err = m.loadActiveAddresses(ctx) + if err != nil { + return err + } + // Communicate to the caller that the address manager has completed its // initialization. close(initChan) @@ -107,54 +127,125 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } -// NewAddress creates a new static address with the server or returns an -// existing one. +// loadActiveAddresses rebuilds the runtime address map from the durable DB +// state and re-imports all scripts into lnd. Importing is intentionally +// idempotent so restart paths repair missing wallet watches before deposit +// discovery starts. +func (m *Manager) loadActiveAddresses(ctx context.Context) error { + params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return err + } + + active := make(map[string]*Parameters, len(params)) + for _, param := range params { + staticAddress, err := staticAddressFromParams(param) + if err != nil { + return err + } + + err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return err + } + + active[string(param.PkScript)] = param + } + + m.Lock() + m.activeStaticAddresses = active + m.Unlock() + + return nil +} + +// NewAddress creates the next externally visible receive static address. +// +// The first call also makes sure the legacy/root static address seed exists, +// because receive and change addresses are derived from the server pubkey and +// expiry returned for that seed. func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, int64, error) { - // If there's already a static address in the database, we can return - // it. - m.Lock() - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.NewReceiveAddress(ctx) if err != nil { - m.Unlock() + return nil, 0, err + } + address, err := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + ) + if err != nil { return nil, 0, err } - if len(addresses) > 0 { - clientPubKey := addresses[0].ClientPubkey - serverPubKey := addresses[0].ServerPubkey - expiry := int64(addresses[0].Expiry) - defer m.Unlock() + return address, int64(params.Expiry), nil +} - address, err := m.GetTaprootAddress( - clientPubKey, serverPubKey, expiry, - ) - if err != nil { - return nil, 0, err +// EnsureStaticAddressSeed loads or creates the legacy/root static address +// parameters. The root address is the only address that requires a Nautilus +// ServerNewAddress call; all receive/change addresses derive client keys +// locally and reuse this server pubkey/expiry seed. +func (m *Manager) EnsureStaticAddressSeed(ctx context.Context) (*Parameters, + error) { + + m.Lock() + seed := m.legacyParameters() + m.Unlock() + if seed != nil { + return seed, nil + } + + m.Lock() + defer m.Unlock() + + // Another caller may have created the seed while we were waiting for the + // issuance lock. + seed = m.legacyParameters() + if seed != nil { + return seed, nil + } + + addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + return nil, err + } + if len(addresses) > 0 { + for _, addr := range addresses { + // Re-import existing rows so startup can repair a DB-only + // address before deposit discovery depends on lnd's wallet + // view. + staticAddress, err := staticAddressFromParams(addr) + if err != nil { + return nil, err + } + + err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, err + } + + m.activeStaticAddresses[string(addr.PkScript)] = addr } - return address, expiry, nil + return addresses[0], nil } - m.Unlock() - // We are fetching a new L402 token from the server. There is one static - // address per L402 token allowed. + // We are fetching a new L402 token from the server. The returned server + // key/expiry is the static address seed for all future client-derived + // addresses for this L402. err = m.cfg.FetchL402(ctx) if err != nil { - return nil, 0, err + return nil, err } clientPubKey, err := m.cfg.WalletKit.DeriveNextKey( ctx, swap.StaticAddressKeyFamily, ) if err != nil { - return nil, 0, err + return nil, err } - // Send our clientPubKey to the server and wait for the server to - // respond with he serverPubKey and the static address CSV expiry. protocolVersion := version.CurrentRPCProtocolVersion() resp, err := m.cfg.AddressClient.ServerNewAddress( ctx, &staticaddressrpc.ServerNewAddressRequest{ @@ -163,78 +254,121 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, }, ) if err != nil { - return nil, 0, err + return nil, err } if resp == nil { - return nil, 0, fmt.Errorf("missing server new address response") + return nil, fmt.Errorf("missing server new address response") } serverParams := resp.GetParams() if err := validateServerAddressParams(serverParams); err != nil { - return nil, 0, err + return nil, err } serverPubKey, err := btcec.ParsePubKey(serverParams.GetServerKey()) if err != nil { - return nil, 0, err + return nil, err + } + + return m.createAddressFromKey( + ctx, clientPubKey, serverPubKey, serverParams.Expiry, + version.AddressProtocolVersion(protocolVersion), + ) +} + +// NewReceiveAddress derives, stores, imports and activates the next receive +// family static address. It is used by `loop static new`. +func (m *Manager) NewReceiveAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err } + return m.newDerivedAddress(ctx, seed, swap.StaticMultiAddressKeyFamily) +} + +// NewChangeAddress derives, stores, imports and activates the next change +// family static address. Swap and withdrawal code calls this before submitting +// requests that require change. +func (m *Manager) NewChangeAddress(ctx context.Context) (*Parameters, error) { + seed, err := m.EnsureStaticAddressSeed(ctx) + if err != nil { + return nil, err + } + + return m.newDerivedAddress(ctx, seed, swap.StaticAddressChangeKeyFamily) +} + +func (m *Manager) newDerivedAddress(ctx context.Context, seed *Parameters, + keyFamily int32) (*Parameters, error) { + + m.Lock() + defer m.Unlock() + + clientPubKey, err := m.cfg.WalletKit.DeriveNextKey(ctx, keyFamily) + if err != nil { + return nil, err + } + + return m.createAddressFromKey( + ctx, clientPubKey, seed.ServerPubkey, seed.Expiry, + seed.ProtocolVersion, + ) +} + +func (m *Manager) createAddressFromKey(ctx context.Context, + clientPubKey *keychain.KeyDescriptor, serverPubKey *btcec.PublicKey, + expiry uint32, protocolVersion version.AddressProtocolVersion) ( + *Parameters, error) { + staticAddress, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(serverParams.Expiry), - clientPubKey.PubKey, serverPubKey, + input.MuSig2Version100RC2, int64(expiry), clientPubKey.PubKey, + serverPubKey, ) if err != nil { - return nil, 0, err + return nil, err } pkScript, err := staticAddress.StaticAddressScript() if err != nil { - return nil, 0, err + return nil, err } - // Create the static address from the parameters the server provided and - // store all parameters in the database. - addrParams := &script.Parameters{ + addrParams := &Parameters{ ClientPubkey: clientPubKey.PubKey, ServerPubkey: serverPubKey, PkScript: pkScript, - Expiry: serverParams.Expiry, + Expiry: expiry, KeyLocator: keychain.KeyLocator{ Family: clientPubKey.Family, Index: clientPubKey.Index, }, - ProtocolVersion: version.AddressProtocolVersion( - protocolVersion, - ), + ProtocolVersion: protocolVersion, InitiationHeight: m.currentHeight.Load(), } - err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) + + // Import before persisting the address row. If lnd rejects the script + // import, a later startup retry should still see a clean missing-address + // state instead of a DB-only static address. + err = m.importAddressTapscript(ctx, staticAddress) if err != nil { - return nil, 0, err + return nil, err } - // Import the static address tapscript into our lnd wallet, so we can - // track unspent outputs of it. - tapScript := input.TapscriptFullTree( - staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, - ) - addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) if err != nil { - return nil, 0, err + return nil, err } - log.Infof("Imported static address taproot script to lnd wallet: %v", - addr) - - address, err := m.GetTaprootAddress( - clientPubKey.PubKey, serverPubKey, int64(serverParams.Expiry), - ) + addrParams.ID, err = m.cfg.Store.GetStaticAddressID(ctx, pkScript) if err != nil { - return nil, 0, err + return nil, err } - return address, int64(serverParams.Expiry), nil + m.activeStaticAddresses[string(pkScript)] = addrParams + + return addrParams, nil } // validateServerAddressParams validates the server-controlled static address @@ -272,6 +406,60 @@ func validateServerAddressParams( return nil } +func (m *Manager) importAddressTapscript(ctx context.Context, + staticAddress *script.StaticAddress) error { + + // Import the static address tapscript into our lnd wallet, so we can + // track unspent outputs of it. + tapScript := input.TapscriptFullTree( + staticAddress.InternalPubKey, *staticAddress.TimeoutLeaf, + ) + addr, err := m.cfg.WalletKit.ImportTaprootScript(ctx, tapScript) + if err != nil { + // Importing into an lnd instance that already knows the script is + // expected on restart. Treat the duplicate import as success. + if strings.Contains(err.Error(), "already exists") { + log.Infof("Static address tapscript already imported") + return nil + } + + return err + } + + log.Infof("Imported static address taproot script to lnd wallet: %v", + addr) + + return nil +} + +func staticAddressFromParams(params *Parameters) (*script.StaticAddress, + error) { + + if params == nil { + return nil, fmt.Errorf("missing static address parameters") + } + + return script.NewStaticAddress( + input.MuSig2Version100RC2, int64(params.Expiry), + params.ClientPubkey, params.ServerPubkey, + ) +} + +func (m *Manager) legacyParameters() *Parameters { + var legacy *Parameters + for _, params := range m.activeStaticAddresses { + if params == nil { + continue + } + + if legacy == nil || params.ID < legacy.ID { + legacy = params + } + } + + return legacy +} + // GetTaprootAddress returns a taproot address for the given client and server // public keys and expiry. func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, @@ -292,21 +480,17 @@ func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, // ListUnspentRaw returns a list of utxos at the static address. func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, - maxConfs int32) (*btcutil.AddressTaproot, []*lnwallet.Utxo, error) { - - addresses, err := m.cfg.Store.GetAllStaticAddresses(ctx) - switch { - case err != nil: - return nil, nil, err - - case len(addresses) == 0: - return nil, nil, nil + maxConfs int32) ([]*lnwallet.Utxo, error) { - case len(addresses) > 1: - return nil, nil, fmt.Errorf("more than one address found") + m.Lock() + active := make(map[string]struct{}, len(m.activeStaticAddresses)) + for pkScript := range m.activeStaticAddresses { + active[pkScript] = struct{}{} + } + m.Unlock() + if len(active) == 0 { + return nil, nil } - - staticAddress := addresses[0] // List all unspent utxos the wallet sees, regardless of the number of // confirmations. @@ -314,43 +498,36 @@ func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, ctx, minConfs, maxConfs, ) if err != nil { - return nil, nil, err + return nil, err } - // Filter the list of lnd's unspent utxos for the pkScript of our static - // address. + // Filter the list of lnd's unspent utxos for any locally active static + // address script. var filteredUtxos []*lnwallet.Utxo for _, utxo := range utxos { - if bytes.Equal(utxo.PkScript, staticAddress.PkScript) { + if _, ok := active[string(utxo.PkScript)]; ok { filteredUtxos = append(filteredUtxos, utxo) } } - taprootAddress, err := m.GetTaprootAddress( - staticAddress.ClientPubkey, staticAddress.ServerPubkey, - int64(staticAddress.Expiry), - ) - if err != nil { - return nil, nil, err - } - - return taprootAddress, filteredUtxos, nil + return filteredUtxos, nil } -// GetStaticAddressParameters returns the parameters of the static address. +// GetStaticAddressParameters returns the legacy/root static-address +// parameters. func (m *Manager) GetStaticAddressParameters(ctx context.Context) ( *script.Parameters, error) { - params, err := m.cfg.Store.GetAllStaticAddresses(ctx) + params, err := m.GetLegacyParameters(ctx) if err != nil { return nil, err } - if len(params) == 0 { - return nil, fmt.Errorf("no static address parameters found") + if params == nil { + return nil, ErrNoStaticAddress } - return params[0], nil + return params, nil } // GetStaticAddress returns a taproot address for the given client and server @@ -363,25 +540,53 @@ func (m *Manager) GetStaticAddress(ctx context.Context) (*script.StaticAddress, return nil, err } - address, err := script.NewStaticAddress( - input.MuSig2Version100RC2, int64(params.Expiry), - params.ClientPubkey, params.ServerPubkey, - ) - if err != nil { - return nil, err - } - - return address, nil + return staticAddressFromParams(params) } // ListUnspent returns a list of utxos at the static address. func (m *Manager) ListUnspent(ctx context.Context, minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { - _, utxos, err := m.ListUnspentRaw(ctx, minConfs, maxConfs) + return m.ListUnspentRaw(ctx, minConfs, maxConfs) +} + +// GetLegacyParameters returns the legacy/root static address parameters. +func (m *Manager) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { + + params, err := m.cfg.Store.GetLegacyParameters(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } if err != nil { return nil, err } - return utxos, nil + return params, nil +} + +// GetParameters returns active static address parameters for a pkScript. +func (m *Manager) GetParameters(pkScript []byte) *Parameters { + m.Lock() + defer m.Unlock() + + return m.activeStaticAddresses[string(pkScript)] +} + +// GetStaticAddressID returns the database row ID for a static address script. +func (m *Manager) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + return m.cfg.Store.GetStaticAddressID(ctx, pkScript) +} + +// IsOurPkScript returns true if the pkScript belongs to an active static +// address. +func (m *Manager) IsOurPkScript(pkScript []byte) bool { + return m.GetParameters(pkScript) != nil +} + +// GetAllAddresses returns all persisted static address parameters. +func (m *Manager) GetAllAddresses(ctx context.Context) ([]*Parameters, error) { + return m.cfg.Store.GetAllStaticAddresses(ctx) } diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index b7bbf79ae..dc59f7518 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -132,6 +132,20 @@ func TestManager(t *testing.T) { // The expiry has to match. require.EqualValues(t, defaultExpiry, expiry) + + storedParams, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.EqualValues( + t, swap.StaticAddressKeyFamily, storedParams.KeyLocator.Family, + ) + + addresses, err := testContext.manager.GetAllAddresses(ctxb) + require.NoError(t, err) + require.Len(t, addresses, 2) + require.EqualValues( + t, swap.StaticMultiAddressKeyFamily, + addresses[1].KeyLocator.Family, + ) } // TestNewAddressValidatesServerResponse tests that the untrusted @@ -233,12 +247,12 @@ func TestNewAddressAcceptsMaxCSVExpiry(t *testing.T) { func GenerateExpectedTaprootAddress(t *ManagerTestContext) ( *btcutil.AddressTaproot, error) { - keyIndex := int32(0) + keyIndex := int32(1) _, pubKey := test.CreateKey(keyIndex) keyDescriptor := &keychain.KeyDescriptor{ KeyLocator: keychain.KeyLocator{ - Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Family: keychain.KeyFamily(swap.StaticMultiAddressKeyFamily), Index: uint32(keyIndex), }, PubKey: pubKey, diff --git a/staticaddr/address/sql_store.go b/staticaddr/address/sql_store.go index 16f113c44..35298867f 100644 --- a/staticaddr/address/sql_store.go +++ b/staticaddr/address/sql_store.go @@ -6,7 +6,6 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/keychain" ) @@ -26,7 +25,7 @@ func NewSqlStore(db *loopdb.BaseDB) *SqlStore { // CreateStaticAddress creates a static address record in the database. func (s *SqlStore) CreateStaticAddress(ctx context.Context, - addrParams *script.Parameters) error { + addrParams *Parameters) error { createArgs := sqlc.CreateStaticAddressParams{ ClientPubkey: addrParams.ClientPubkey.SerializeCompressed(), @@ -51,14 +50,14 @@ func (s *SqlStore) GetStaticAddressID(ctx context.Context, // GetAllStaticAddresses returns all addresses known to the client. func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( - []*script.Parameters, error) { + []*Parameters, error) { staticAddresses, err := s.baseDB.Queries.AllStaticAddresses(ctx) if err != nil { return nil, err } - var result []*script.Parameters + var result []*Parameters for _, address := range staticAddresses { res, err := s.toAddressParameters(address) if err != nil { @@ -72,8 +71,8 @@ func (s *SqlStore) GetAllStaticAddresses(ctx context.Context) ( } // GetLegacyParameters returns the first static address created for this L402. -func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( - *script.Parameters, error) { +func (s *SqlStore) GetLegacyParameters(ctx context.Context) (*Parameters, + error) { staticAddress, err := s.baseDB.Queries.GetLegacyAddress(ctx) if err != nil { @@ -86,7 +85,7 @@ func (s *SqlStore) GetLegacyParameters(ctx context.Context) ( // toAddressParameters transforms a database representation of a static address // to an AddressParameters struct. func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( - *script.Parameters, error) { + *Parameters, error) { clientPubkey, err := btcec.ParsePubKey(row.ClientPubkey) if err != nil { @@ -98,7 +97,7 @@ func (s *SqlStore) toAddressParameters(row sqlc.StaticAddress) ( return nil, err } - return &script.Parameters{ + return &Parameters{ ID: row.ID, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, From 020a62f4c21382ac2240b453f660edc8faa040b2 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:06 +0200 Subject: [PATCH 25/35] staticaddr/deposit: discover all active addresses Filter wallet UTXOs against every active static-address script and attach the matching address parameters to new deposits. This makes deposits to derived receive addresses visible to the deposit manager. --- staticaddr/deposit/deposit.go | 3 +- staticaddr/deposit/interface.go | 9 ++++++ staticaddr/deposit/manager.go | 12 ++++++++ staticaddr/deposit/manager_test.go | 43 +++++++++++++++++++++++++++++ staticaddr/deposit/sql_store.go | 4 +-- staticaddr/staticutil/utils_test.go | 4 +-- 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 8d5fa4636..043139122 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" @@ -76,7 +77,7 @@ type Deposit struct { // AddressParams are the static address parameters that produced this // deposit's pkScript. Spending code must use these per-deposit // parameters rather than assuming all deposits belong to one address. - AddressParams *script.Parameters + AddressParams *address.Parameters } // IsInFinalState returns true if the deposit is final. diff --git a/staticaddr/deposit/interface.go b/staticaddr/deposit/interface.go index 8606c7e60..0a3c6170d 100644 --- a/staticaddr/deposit/interface.go +++ b/staticaddr/deposit/interface.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -39,6 +40,14 @@ type AddressManager interface { GetStaticAddressParameters(ctx context.Context) (*script.Parameters, error) + // GetStaticAddressID returns the database ID for the static address + // behind the given pkScript. + GetStaticAddressID(ctx context.Context, pkScript []byte) (int32, error) + + // GetParameters returns active static address parameters for the given + // pkScript. + GetParameters(pkScript []byte) *address.Parameters + // GetStaticAddress returns the deposit address for the given // client and server public keys. GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 30f4532fb..3c627e29a 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -376,6 +376,17 @@ func (m *Manager) createNewDeposit(ctx context.Context, if err != nil { return nil, err } + + addressParams := m.cfg.AddressManager.GetParameters(utxo.PkScript) + if addressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %v", utxo.OutPoint) + } + if addressParams.ID <= 0 { + return nil, fmt.Errorf("missing static address ID for deposit %v", + utxo.OutPoint) + } + deposit := &Deposit{ ID: id, state: Deposited, @@ -383,6 +394,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, Value: utxo.Value, ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, + AddressParams: addressParams, } err = m.cfg.Store.CreateDeposit(ctx, deposit) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 8904a515f..d2d78a5c6 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -112,6 +112,16 @@ type mockAddressManager struct { mock.Mock } +func (m *mockAddressManager) hasExpectation(method string) bool { + for _, call := range m.ExpectedCalls { + if call.Method == method { + return true + } + } + + return false +} + func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) ( *script.Parameters, error) { @@ -121,6 +131,39 @@ func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) ( args.Error(1) } +func (m *mockAddressManager) GetStaticAddressID(ctx context.Context, + pkScript []byte) (int32, error) { + + if !m.hasExpectation("GetStaticAddressID") { + return 1, nil + } + + args := m.Called(ctx, pkScript) + + return int32(args.Int(0)), args.Error(1) +} + +func (m *mockAddressManager) GetParameters( + pkScript []byte) *address.Parameters { + + if !m.hasExpectation("GetParameters") { + return &address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + } + } + + args := m.Called(pkScript) + if args.Get(0) == nil { + return nil + } + + return args.Get(0).(*address.Parameters) +} + func (m *mockAddressManager) GetStaticAddress(ctx context.Context) ( *script.StaticAddress, error) { diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index dcacded57..0706fb304 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -15,7 +15,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" - "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/keychain" @@ -439,7 +439,7 @@ func toDeposit(row depositRow, lastUpdate sqlc.DepositUpdate) (*Deposit, return nil, err } - deposit.AddressParams = &script.Parameters{ + deposit.AddressParams = &address.Parameters{ ID: row.StaticAddressID.Int32, ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index ae68b4895..aaed343e5 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -174,7 +174,7 @@ func TestCreateMusig2Session_Success(t *testing.T) { serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - params := &script.Parameters{ + params := &address.Parameters{ ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Expiry: 10, @@ -203,7 +203,7 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { serverKey, err := btcec.NewPrivateKey() require.NoError(t, err) - params := &script.Parameters{ + params := &address.Parameters{ ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Expiry: 12, From c914ef2c4a32bcba05306cdc13805a3adad95fe5 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:13 +0200 Subject: [PATCH 26/35] staticaddr/deposit: sweep with owning address keys Build timeout sweeps from each deposit own script, expiry, and key locator. Derived-address deposits can now use their unilateral recovery path without falling back to the legacy root parameters. --- staticaddr/deposit/actions.go | 26 +++++++---------- staticaddr/deposit/actions_test.go | 46 ++++++++++++++++++++++++++++++ staticaddr/deposit/fsm.go | 18 ++++++------ 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 77560eb24..03a65e00b 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" @@ -27,9 +26,15 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, msgTx := wire.NewMsgTx(2) - params, err := f.cfg.AddressManager.GetStaticAddressParameters(ctx) + if f.deposit.AddressParams == nil { + return f.HandleError(fmt.Errorf("missing static address " + + "parameters")) + } + params := f.deposit.AddressParams + + address, err := f.deposit.GetStaticAddressScript() if err != nil { - return fsm.OnError + return f.HandleError(err) } // Add the deposit outpoint as input to the transaction. @@ -96,11 +101,6 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, return f.HandleError(err) } - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) - if err != nil { - return f.HandleError(err) - } - sig := rawSigs[0] msgTx.TxIn[0].Witness, err = address.GenTimeoutWitness(sig) if err != nil { @@ -131,14 +131,10 @@ func (f *FSM) PublishDepositExpirySweepAction(ctx context.Context, func (f *FSM) WaitForExpirySweepAction(ctx context.Context, _ fsm.EventContext) fsm.EventType { - var txID *chainhash.Hash - // Only pass the txid if we know it from our own publication. - if f.deposit.ExpirySweepTxid != (chainhash.Hash{}) { - txID = &f.deposit.ExpirySweepTxid - } - + // Register by script only so an RBF replacement of the timeout sweep is + // still detected after restart with a stale ExpirySweepTxid. spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll - ctx, txID, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, + ctx, nil, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, int32(f.deposit.GetConfirmationHeight()), ) if err != nil { diff --git a/staticaddr/deposit/actions_test.go b/staticaddr/deposit/actions_test.go index 8c0211211..15a913999 100644 --- a/staticaddr/deposit/actions_test.go +++ b/staticaddr/deposit/actions_test.go @@ -8,6 +8,8 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -52,6 +54,50 @@ func TestFinalizeDepositActionDoesNotBlock(t *testing.T) { } } +func TestWaitForExpirySweepActionRegistersByScriptOnly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + timeoutPkScript := []byte{0x51, 0x20, 0x01} + confChan := make(chan *chainntnfs.TxConfirmation, 1) + errChan := make(chan error, 1) + + chainNotifier := &MockChainNotifier{} + chainNotifier.On( + "RegisterConfirmationsNtfn", + mock.Anything, + mock.MatchedBy(func(txid *chainhash.Hash) bool { + return txid == nil + }), + timeoutPkScript, + int32(DefaultConfTarget), + int32(42), + ).Return(confChan, errChan, nil).Once() + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + ChainNotifier: chainNotifier, + }, + deposit: &Deposit{ + ConfirmationHeight: 42, + ExpirySweepTxid: chainhash.Hash{9}, + TimeOutSweepPkScript: timeoutPkScript, + }, + } + + confirmedTx := wire.NewMsgTx(2) + confirmedTx.AddTxOut(&wire.TxOut{ + Value: 1000, + PkScript: timeoutPkScript, + }) + confChan <- &chainntnfs.TxConfirmation{Tx: confirmedTx} + + event := depositFSM.WaitForExpirySweepAction(ctx, nil) + require.Equal(t, OnExpirySwept, event) + require.Equal(t, confirmedTx.TxHash(), depositFSM.deposit.ExpirySweepTxid) + chainNotifier.AssertExpectations(t) +} + // TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup // notification is tied to the FSM lifetime, not the caller's request context. func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) { diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index c5bb85c30..723aaa6aa 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -181,13 +181,13 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, finalizedDepositChan chan wire.OutPoint, recoverStateMachine bool) (*FSM, error) { - params, err := cfg.AddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return nil, fmt.Errorf("unable to get static address "+ - "parameters: %w", err) + if deposit.AddressParams == nil { + return nil, fmt.Errorf("missing deposit static address " + + "parameters") } + params := deposit.AddressParams - address, err := cfg.AddressManager.GetStaticAddress(ctx) + address, err := deposit.GetStaticAddressScript() if err != nil { return nil, fmt.Errorf("unable to get static address: %w", err) } @@ -535,10 +535,10 @@ func (f *FSM) Errorf(format string, args ...any) { } // SignDescriptor returns the sign descriptor for the static address output. -func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, +func (f *FSM) SignDescriptor(_ context.Context) (*lndclient.SignDescriptor, error) { - address, err := f.cfg.AddressManager.GetStaticAddress(ctx) + address, err := f.deposit.GetStaticAddressScript() if err != nil { return nil, err } @@ -546,10 +546,10 @@ func (f *FSM) SignDescriptor(ctx context.Context) (*lndclient.SignDescriptor, return &lndclient.SignDescriptor{ WitnessScript: address.TimeoutLeaf.Script, KeyDesc: keychain.KeyDescriptor{ - PubKey: f.params.ClientPubkey, + PubKey: f.deposit.AddressParams.ClientPubkey, }, Output: wire.NewTxOut( - int64(f.deposit.Value), f.params.PkScript, + int64(f.deposit.Value), f.deposit.AddressParams.PkScript, ), HashType: txscript.SigHashDefault, InputIndex: 0, From a4df9e11ff4fefa597124a201833ea764aaf656f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:13 +0200 Subject: [PATCH 27/35] 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 | 96 +++++++++++++++----- staticaddr/staticutil/utils_test.go | 135 ++++++++++++++++++++++------ staticaddr/withdraw/manager.go | 33 +++---- 6 files changed, 213 insertions(+), 76 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 2ae1a7eb0..608ddb1ae 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -595,8 +595,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) @@ -606,8 +605,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) @@ -615,8 +613,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 7fcc3ff9a..25bd004af 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 92c4a9f14..0772252a0 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -373,8 +373,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, @@ -393,7 +403,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 a25093339..17da06569 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 { + 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), + ) + } + session, err := CreateMusig2Session( - ctx, signer, addrParams, staticAddress, + ctx, signer, deposit, ) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, errors.Join( + err, CleanupMusig2Sessions(ctx, signer, sessions), + ) } - sessions[deposit.String()] = session - nonces[deposit.String()] = session.PublicNonce[:] - depositToIdx[deposit.String()] = i + 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 aaed343e5..4f32eadea 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 3a7927a45..002ee25c1 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 } From a53b3059944e9d425e3e3f564d48e59b0d3a6de4 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 6 May 2026 14:58:40 +0200 Subject: [PATCH 28/35] swapserverrpc: add multi-address proofs Carry each deposit client derivation key and an optional generated change descriptor in static-address loop-in and withdrawal requests. The server can use these fields to validate scripts it does not store directly. --- swapserverrpc/staticaddr.pb.go | 284 ++++++++++++++++++++++++--------- swapserverrpc/staticaddr.proto | 29 ++++ 2 files changed, 234 insertions(+), 79 deletions(-) diff --git a/swapserverrpc/staticaddr.pb.go b/swapserverrpc/staticaddr.pb.go index 77ad9799e..b7c7e3ef8 100644 --- a/swapserverrpc/staticaddr.pb.go +++ b/swapserverrpc/staticaddr.pb.go @@ -387,8 +387,15 @@ type ServerPsbtWithdrawRequest struct { WithdrawalPsbt []byte `protobuf:"bytes,1,opt,name=withdrawal_psbt,json=withdrawalPsbt,proto3" json:"withdrawal_psbt,omitempty"` // The map of deposit txid:idx to the nonce used by the client. DepositToNonces map[string][]byte `protobuf:"bytes,2,rep,name=deposit_to_nonces,json=depositToNonces,proto3" json:"deposit_to_nonces,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + DepositToClientPubkeys map[string][]byte `protobuf:"bytes,3,rep,name=deposit_to_client_pubkeys,json=depositToClientPubkeys,proto3" json:"deposit_to_client_pubkeys,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional change output metadata for withdrawals that return funds to a + // newly generated static address. + ChangeOutput *StaticAddressChangeOutput `protobuf:"bytes,4,opt,name=change_output,json=changeOutput,proto3" json:"change_output,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ServerPsbtWithdrawRequest) Reset() { @@ -435,6 +442,20 @@ func (x *ServerPsbtWithdrawRequest) GetDepositToNonces() map[string][]byte { return nil } +func (x *ServerPsbtWithdrawRequest) GetDepositToClientPubkeys() map[string][]byte { + if x != nil { + return x.DepositToClientPubkeys + } + return nil +} + +func (x *ServerPsbtWithdrawRequest) GetChangeOutput() *StaticAddressChangeOutput { + if x != nil { + return x.ChangeOutput + } + return nil +} + type ServerPsbtWithdrawResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The txid of the psbt that the client wants to push the sigs for. @@ -544,6 +565,69 @@ func (x *ServerPsbtWithdrawSigningInfo) GetSig() []byte { return nil } +type StaticAddressChangeOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The client static address pubkey used to derive the change output. + ClientPubkey []byte `protobuf:"bytes,1,opt,name=client_pubkey,json=clientPubkey,proto3" json:"client_pubkey,omitempty"` + // The expected output script for the static address change output. + PkScript []byte `protobuf:"bytes,2,opt,name=pk_script,json=pkScript,proto3" json:"pk_script,omitempty"` + // The expected change amount in satoshis. + Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StaticAddressChangeOutput) Reset() { + *x = StaticAddressChangeOutput{} + mi := &file_staticaddr_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StaticAddressChangeOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StaticAddressChangeOutput) ProtoMessage() {} + +func (x *StaticAddressChangeOutput) ProtoReflect() protoreflect.Message { + mi := &file_staticaddr_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StaticAddressChangeOutput.ProtoReflect.Descriptor instead. +func (*StaticAddressChangeOutput) Descriptor() ([]byte, []int) { + return file_staticaddr_proto_rawDescGZIP(), []int{8} +} + +func (x *StaticAddressChangeOutput) GetClientPubkey() []byte { + if x != nil { + return x.ClientPubkey + } + return nil +} + +func (x *StaticAddressChangeOutput) GetPkScript() []byte { + if x != nil { + return x.PkScript + } + return nil +} + +func (x *StaticAddressChangeOutput) GetAmount() int64 { + if x != nil { + return x.Amount + } + return 0 +} + type ServerStaticAddressLoopInRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The client's public key for the htlc output. @@ -589,14 +673,21 @@ type ServerStaticAddressLoopInRequest struct { Amount uint64 `protobuf:"varint,9,opt,name=amount,proto3" json:"amount,omitempty"` // If set, request the server to use fast publication behavior for this // swap. - Fast bool `protobuf:"varint,10,opt,name=fast,proto3" json:"fast,omitempty"` + Fast bool `protobuf:"varint,10,opt,name=fast,proto3" json:"fast,omitempty"` + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + DepositToClientPubkeys map[string][]byte `protobuf:"bytes,11,rep,name=deposit_to_client_pubkeys,json=depositToClientPubkeys,proto3" json:"deposit_to_client_pubkeys,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Optional change output metadata for fractional loop-ins that return + // funds to a newly generated static address. + ChangeOutput *StaticAddressChangeOutput `protobuf:"bytes,12,opt,name=change_output,json=changeOutput,proto3" json:"change_output,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ServerStaticAddressLoopInRequest) Reset() { *x = ServerStaticAddressLoopInRequest{} - mi := &file_staticaddr_proto_msgTypes[8] + mi := &file_staticaddr_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -608,7 +699,7 @@ func (x *ServerStaticAddressLoopInRequest) String() string { func (*ServerStaticAddressLoopInRequest) ProtoMessage() {} func (x *ServerStaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[8] + mi := &file_staticaddr_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -621,7 +712,7 @@ func (x *ServerStaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerStaticAddressLoopInRequest.ProtoReflect.Descriptor instead. func (*ServerStaticAddressLoopInRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{8} + return file_staticaddr_proto_rawDescGZIP(), []int{9} } func (x *ServerStaticAddressLoopInRequest) GetHtlcClientPubKey() []byte { @@ -694,6 +785,20 @@ func (x *ServerStaticAddressLoopInRequest) GetFast() bool { return false } +func (x *ServerStaticAddressLoopInRequest) GetDepositToClientPubkeys() map[string][]byte { + if x != nil { + return x.DepositToClientPubkeys + } + return nil +} + +func (x *ServerStaticAddressLoopInRequest) GetChangeOutput() *StaticAddressChangeOutput { + if x != nil { + return x.ChangeOutput + } + return nil +} + type ServerStaticAddressLoopInResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The server's public key for the htlc output. @@ -715,7 +820,7 @@ type ServerStaticAddressLoopInResponse struct { func (x *ServerStaticAddressLoopInResponse) Reset() { *x = ServerStaticAddressLoopInResponse{} - mi := &file_staticaddr_proto_msgTypes[9] + mi := &file_staticaddr_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -727,7 +832,7 @@ func (x *ServerStaticAddressLoopInResponse) String() string { func (*ServerStaticAddressLoopInResponse) ProtoMessage() {} func (x *ServerStaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[9] + mi := &file_staticaddr_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -740,7 +845,7 @@ func (x *ServerStaticAddressLoopInResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ServerStaticAddressLoopInResponse.ProtoReflect.Descriptor instead. func (*ServerStaticAddressLoopInResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{9} + return file_staticaddr_proto_rawDescGZIP(), []int{10} } func (x *ServerStaticAddressLoopInResponse) GetHtlcServerPubKey() []byte { @@ -790,7 +895,7 @@ type ServerHtlcSigningInfo struct { func (x *ServerHtlcSigningInfo) Reset() { *x = ServerHtlcSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[10] + mi := &file_staticaddr_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -802,7 +907,7 @@ func (x *ServerHtlcSigningInfo) String() string { func (*ServerHtlcSigningInfo) ProtoMessage() {} func (x *ServerHtlcSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[10] + mi := &file_staticaddr_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -815,7 +920,7 @@ func (x *ServerHtlcSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ServerHtlcSigningInfo.ProtoReflect.Descriptor instead. func (*ServerHtlcSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{10} + return file_staticaddr_proto_rawDescGZIP(), []int{11} } func (x *ServerHtlcSigningInfo) GetNonces() [][]byte { @@ -848,7 +953,7 @@ type PushStaticAddressHtlcSigsRequest struct { func (x *PushStaticAddressHtlcSigsRequest) Reset() { *x = PushStaticAddressHtlcSigsRequest{} - mi := &file_staticaddr_proto_msgTypes[11] + mi := &file_staticaddr_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -860,7 +965,7 @@ func (x *PushStaticAddressHtlcSigsRequest) String() string { func (*PushStaticAddressHtlcSigsRequest) ProtoMessage() {} func (x *PushStaticAddressHtlcSigsRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[11] + mi := &file_staticaddr_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -873,7 +978,7 @@ func (x *PushStaticAddressHtlcSigsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PushStaticAddressHtlcSigsRequest.ProtoReflect.Descriptor instead. func (*PushStaticAddressHtlcSigsRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{11} + return file_staticaddr_proto_rawDescGZIP(), []int{12} } func (x *PushStaticAddressHtlcSigsRequest) GetSwapHash() []byte { @@ -916,7 +1021,7 @@ type ClientHtlcSigningInfo struct { func (x *ClientHtlcSigningInfo) Reset() { *x = ClientHtlcSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[12] + mi := &file_staticaddr_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -928,7 +1033,7 @@ func (x *ClientHtlcSigningInfo) String() string { func (*ClientHtlcSigningInfo) ProtoMessage() {} func (x *ClientHtlcSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[12] + mi := &file_staticaddr_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -941,7 +1046,7 @@ func (x *ClientHtlcSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientHtlcSigningInfo.ProtoReflect.Descriptor instead. func (*ClientHtlcSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{12} + return file_staticaddr_proto_rawDescGZIP(), []int{13} } func (x *ClientHtlcSigningInfo) GetNonces() [][]byte { @@ -966,7 +1071,7 @@ type PushStaticAddressHtlcSigsResponse struct { func (x *PushStaticAddressHtlcSigsResponse) Reset() { *x = PushStaticAddressHtlcSigsResponse{} - mi := &file_staticaddr_proto_msgTypes[13] + mi := &file_staticaddr_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -978,7 +1083,7 @@ func (x *PushStaticAddressHtlcSigsResponse) String() string { func (*PushStaticAddressHtlcSigsResponse) ProtoMessage() {} func (x *PushStaticAddressHtlcSigsResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[13] + mi := &file_staticaddr_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -991,7 +1096,7 @@ func (x *PushStaticAddressHtlcSigsResponse) ProtoReflect() protoreflect.Message // Deprecated: Use PushStaticAddressHtlcSigsResponse.ProtoReflect.Descriptor instead. func (*PushStaticAddressHtlcSigsResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{13} + return file_staticaddr_proto_rawDescGZIP(), []int{14} } type PushStaticAddressSweeplessSigsRequest struct { @@ -1013,7 +1118,7 @@ type PushStaticAddressSweeplessSigsRequest struct { func (x *PushStaticAddressSweeplessSigsRequest) Reset() { *x = PushStaticAddressSweeplessSigsRequest{} - mi := &file_staticaddr_proto_msgTypes[14] + mi := &file_staticaddr_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1025,7 +1130,7 @@ func (x *PushStaticAddressSweeplessSigsRequest) String() string { func (*PushStaticAddressSweeplessSigsRequest) ProtoMessage() {} func (x *PushStaticAddressSweeplessSigsRequest) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[14] + mi := &file_staticaddr_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1038,7 +1143,7 @@ func (x *PushStaticAddressSweeplessSigsRequest) ProtoReflect() protoreflect.Mess // Deprecated: Use PushStaticAddressSweeplessSigsRequest.ProtoReflect.Descriptor instead. func (*PushStaticAddressSweeplessSigsRequest) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{14} + return file_staticaddr_proto_rawDescGZIP(), []int{15} } func (x *PushStaticAddressSweeplessSigsRequest) GetSwapHash() []byte { @@ -1082,7 +1187,7 @@ type ClientSweeplessSigningInfo struct { func (x *ClientSweeplessSigningInfo) Reset() { *x = ClientSweeplessSigningInfo{} - mi := &file_staticaddr_proto_msgTypes[15] + mi := &file_staticaddr_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1199,7 @@ func (x *ClientSweeplessSigningInfo) String() string { func (*ClientSweeplessSigningInfo) ProtoMessage() {} func (x *ClientSweeplessSigningInfo) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[15] + mi := &file_staticaddr_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1212,7 @@ func (x *ClientSweeplessSigningInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientSweeplessSigningInfo.ProtoReflect.Descriptor instead. func (*ClientSweeplessSigningInfo) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{15} + return file_staticaddr_proto_rawDescGZIP(), []int{16} } func (x *ClientSweeplessSigningInfo) GetNonce() []byte { @@ -1132,7 +1237,7 @@ type PushStaticAddressSweeplessSigsResponse struct { func (x *PushStaticAddressSweeplessSigsResponse) Reset() { *x = PushStaticAddressSweeplessSigsResponse{} - mi := &file_staticaddr_proto_msgTypes[16] + mi := &file_staticaddr_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1144,7 +1249,7 @@ func (x *PushStaticAddressSweeplessSigsResponse) String() string { func (*PushStaticAddressSweeplessSigsResponse) ProtoMessage() {} func (x *PushStaticAddressSweeplessSigsResponse) ProtoReflect() protoreflect.Message { - mi := &file_staticaddr_proto_msgTypes[16] + mi := &file_staticaddr_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1157,7 +1262,7 @@ func (x *PushStaticAddressSweeplessSigsResponse) ProtoReflect() protoreflect.Mes // Deprecated: Use PushStaticAddressSweeplessSigsResponse.ProtoReflect.Descriptor instead. func (*PushStaticAddressSweeplessSigsResponse) Descriptor() ([]byte, []int) { - return file_staticaddr_proto_rawDescGZIP(), []int{16} + return file_staticaddr_proto_rawDescGZIP(), []int{17} } var File_staticaddr_proto protoreflect.FileDescriptor @@ -1184,12 +1289,17 @@ const file_staticaddr_proto_rawDesc = "" + "\rchange_amount\x18\x06 \x01(\x03R\fchangeAmount:\x02\x18\x01\"m\n" + "\x16ServerWithdrawResponse\x12*\n" + "\x11musig2_sweep_sigs\x18\x01 \x03(\fR\x0fmusig2SweepSigs\x12#\n" + - "\rserver_nonces\x18\x02 \x03(\fR\fserverNonces:\x02\x18\x01\"\xed\x01\n" + + "\rserver_nonces\x18\x02 \x03(\fR\fserverNonces:\x02\x18\x01\"\xfc\x03\n" + "\x19ServerPsbtWithdrawRequest\x12'\n" + "\x0fwithdrawal_psbt\x18\x01 \x01(\fR\x0ewithdrawalPsbt\x12c\n" + - "\x11deposit_to_nonces\x18\x02 \x03(\v27.looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntryR\x0fdepositToNonces\x1aB\n" + + "\x11deposit_to_nonces\x18\x02 \x03(\v27.looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntryR\x0fdepositToNonces\x12y\n" + + "\x19deposit_to_client_pubkeys\x18\x03 \x03(\v2>.looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntryR\x16depositToClientPubkeys\x12G\n" + + "\rchange_output\x18\x04 \x01(\v2\".looprpc.StaticAddressChangeOutputR\fchangeOutput\x1aB\n" + "\x14DepositToNoncesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\x1aI\n" + + "\x1bDepositToClientPubkeysEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xf1\x01\n" + "\x1aServerPsbtWithdrawResponse\x12\x12\n" + "\x04txid\x18\x01 \x01(\fR\x04txid\x12W\n" + @@ -1199,7 +1309,11 @@ const file_staticaddr_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2&.looprpc.ServerPsbtWithdrawSigningInfoR\x05value:\x028\x01\"G\n" + "\x1dServerPsbtWithdrawSigningInfo\x12\x14\n" + "\x05nonce\x18\x01 \x01(\fR\x05nonce\x12\x10\n" + - "\x03sig\x18\x02 \x01(\fR\x03sig\"\xae\x03\n" + + "\x03sig\x18\x02 \x01(\fR\x03sig\"u\n" + + "\x19StaticAddressChangeOutput\x12#\n" + + "\rclient_pubkey\x18\x01 \x01(\fR\fclientPubkey\x12\x1b\n" + + "\tpk_script\x18\x02 \x01(\fR\bpkScript\x12\x16\n" + + "\x06amount\x18\x03 \x01(\x03R\x06amount\"\xc5\x05\n" + " ServerStaticAddressLoopInRequest\x12-\n" + "\x13htlc_client_pub_key\x18\x01 \x01(\fR\x10htlcClientPubKey\x12\x1b\n" + "\tswap_hash\x18\x02 \x01(\fR\bswapHash\x12+\n" + @@ -1212,7 +1326,12 @@ const file_staticaddr_proto_rawDesc = "" + "\x17payment_timeout_seconds\x18\b \x01(\rR\x15paymentTimeoutSeconds\x12\x16\n" + "\x06amount\x18\t \x01(\x04R\x06amount\x12\x12\n" + "\x04fast\x18\n" + - " \x01(\bR\x04fast\"\xe1\x02\n" + + " \x01(\bR\x04fast\x12\x80\x01\n" + + "\x19deposit_to_client_pubkeys\x18\v \x03(\v2E.looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntryR\x16depositToClientPubkeys\x12G\n" + + "\rchange_output\x18\f \x01(\v2\".looprpc.StaticAddressChangeOutputR\fchangeOutput\x1aI\n" + + "\x1bDepositToClientPubkeysEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xe1\x02\n" + "!ServerStaticAddressLoopInResponse\x12-\n" + "\x13htlc_server_pub_key\x18\x01 \x01(\fR\x10htlcServerPubKey\x12\x1f\n" + "\vhtlc_expiry\x18\x02 \x01(\x05R\n" + @@ -1267,7 +1386,7 @@ func file_staticaddr_proto_rawDescGZIP() []byte { } var file_staticaddr_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_staticaddr_proto_msgTypes = make([]protoimpl.MessageInfo, 20) +var file_staticaddr_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_staticaddr_proto_goTypes = []any{ (StaticAddressProtocolVersion)(0), // 0: looprpc.StaticAddressProtocolVersion (*ServerNewAddressRequest)(nil), // 1: looprpc.ServerNewAddressRequest @@ -1278,53 +1397,60 @@ var file_staticaddr_proto_goTypes = []any{ (*ServerPsbtWithdrawRequest)(nil), // 6: looprpc.ServerPsbtWithdrawRequest (*ServerPsbtWithdrawResponse)(nil), // 7: looprpc.ServerPsbtWithdrawResponse (*ServerPsbtWithdrawSigningInfo)(nil), // 8: looprpc.ServerPsbtWithdrawSigningInfo - (*ServerStaticAddressLoopInRequest)(nil), // 9: looprpc.ServerStaticAddressLoopInRequest - (*ServerStaticAddressLoopInResponse)(nil), // 10: looprpc.ServerStaticAddressLoopInResponse - (*ServerHtlcSigningInfo)(nil), // 11: looprpc.ServerHtlcSigningInfo - (*PushStaticAddressHtlcSigsRequest)(nil), // 12: looprpc.PushStaticAddressHtlcSigsRequest - (*ClientHtlcSigningInfo)(nil), // 13: looprpc.ClientHtlcSigningInfo - (*PushStaticAddressHtlcSigsResponse)(nil), // 14: looprpc.PushStaticAddressHtlcSigsResponse - (*PushStaticAddressSweeplessSigsRequest)(nil), // 15: looprpc.PushStaticAddressSweeplessSigsRequest - (*ClientSweeplessSigningInfo)(nil), // 16: looprpc.ClientSweeplessSigningInfo - (*PushStaticAddressSweeplessSigsResponse)(nil), // 17: looprpc.PushStaticAddressSweeplessSigsResponse - nil, // 18: looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry - nil, // 19: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry - nil, // 20: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry - (*PrevoutInfo)(nil), // 21: looprpc.PrevoutInfo + (*StaticAddressChangeOutput)(nil), // 9: looprpc.StaticAddressChangeOutput + (*ServerStaticAddressLoopInRequest)(nil), // 10: looprpc.ServerStaticAddressLoopInRequest + (*ServerStaticAddressLoopInResponse)(nil), // 11: looprpc.ServerStaticAddressLoopInResponse + (*ServerHtlcSigningInfo)(nil), // 12: looprpc.ServerHtlcSigningInfo + (*PushStaticAddressHtlcSigsRequest)(nil), // 13: looprpc.PushStaticAddressHtlcSigsRequest + (*ClientHtlcSigningInfo)(nil), // 14: looprpc.ClientHtlcSigningInfo + (*PushStaticAddressHtlcSigsResponse)(nil), // 15: looprpc.PushStaticAddressHtlcSigsResponse + (*PushStaticAddressSweeplessSigsRequest)(nil), // 16: looprpc.PushStaticAddressSweeplessSigsRequest + (*ClientSweeplessSigningInfo)(nil), // 17: looprpc.ClientSweeplessSigningInfo + (*PushStaticAddressSweeplessSigsResponse)(nil), // 18: looprpc.PushStaticAddressSweeplessSigsResponse + nil, // 19: looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry + nil, // 20: looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntry + nil, // 21: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry + nil, // 22: looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntry + nil, // 23: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry + (*PrevoutInfo)(nil), // 24: looprpc.PrevoutInfo } var file_staticaddr_proto_depIdxs = []int32{ 0, // 0: looprpc.ServerNewAddressRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion 3, // 1: looprpc.ServerNewAddressResponse.params:type_name -> looprpc.ServerAddressParameters - 21, // 2: looprpc.ServerWithdrawRequest.outpoints:type_name -> looprpc.PrevoutInfo - 18, // 3: looprpc.ServerPsbtWithdrawRequest.deposit_to_nonces:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry - 19, // 4: looprpc.ServerPsbtWithdrawResponse.signing_info:type_name -> looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry - 0, // 5: looprpc.ServerStaticAddressLoopInRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion - 11, // 6: looprpc.ServerStaticAddressLoopInResponse.standard_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 11, // 7: looprpc.ServerStaticAddressLoopInResponse.high_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 11, // 8: looprpc.ServerStaticAddressLoopInResponse.extreme_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo - 13, // 9: looprpc.PushStaticAddressHtlcSigsRequest.standard_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 13, // 10: looprpc.PushStaticAddressHtlcSigsRequest.high_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 13, // 11: looprpc.PushStaticAddressHtlcSigsRequest.extreme_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo - 20, // 12: looprpc.PushStaticAddressSweeplessSigsRequest.signing_info:type_name -> looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry - 8, // 13: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry.value:type_name -> looprpc.ServerPsbtWithdrawSigningInfo - 16, // 14: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry.value:type_name -> looprpc.ClientSweeplessSigningInfo - 1, // 15: looprpc.StaticAddressServer.ServerNewAddress:input_type -> looprpc.ServerNewAddressRequest - 4, // 16: looprpc.StaticAddressServer.ServerWithdrawDeposits:input_type -> looprpc.ServerWithdrawRequest - 6, // 17: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:input_type -> looprpc.ServerPsbtWithdrawRequest - 9, // 18: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:input_type -> looprpc.ServerStaticAddressLoopInRequest - 12, // 19: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:input_type -> looprpc.PushStaticAddressHtlcSigsRequest - 15, // 20: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:input_type -> looprpc.PushStaticAddressSweeplessSigsRequest - 2, // 21: looprpc.StaticAddressServer.ServerNewAddress:output_type -> looprpc.ServerNewAddressResponse - 5, // 22: looprpc.StaticAddressServer.ServerWithdrawDeposits:output_type -> looprpc.ServerWithdrawResponse - 7, // 23: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:output_type -> looprpc.ServerPsbtWithdrawResponse - 10, // 24: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:output_type -> looprpc.ServerStaticAddressLoopInResponse - 14, // 25: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:output_type -> looprpc.PushStaticAddressHtlcSigsResponse - 17, // 26: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:output_type -> looprpc.PushStaticAddressSweeplessSigsResponse - 21, // [21:27] is the sub-list for method output_type - 15, // [15:21] is the sub-list for method input_type - 15, // [15:15] is the sub-list for extension type_name - 15, // [15:15] is the sub-list for extension extendee - 0, // [0:15] is the sub-list for field type_name + 24, // 2: looprpc.ServerWithdrawRequest.outpoints:type_name -> looprpc.PrevoutInfo + 19, // 3: looprpc.ServerPsbtWithdrawRequest.deposit_to_nonces:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToNoncesEntry + 20, // 4: looprpc.ServerPsbtWithdrawRequest.deposit_to_client_pubkeys:type_name -> looprpc.ServerPsbtWithdrawRequest.DepositToClientPubkeysEntry + 9, // 5: looprpc.ServerPsbtWithdrawRequest.change_output:type_name -> looprpc.StaticAddressChangeOutput + 21, // 6: looprpc.ServerPsbtWithdrawResponse.signing_info:type_name -> looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry + 0, // 7: looprpc.ServerStaticAddressLoopInRequest.protocol_version:type_name -> looprpc.StaticAddressProtocolVersion + 22, // 8: looprpc.ServerStaticAddressLoopInRequest.deposit_to_client_pubkeys:type_name -> looprpc.ServerStaticAddressLoopInRequest.DepositToClientPubkeysEntry + 9, // 9: looprpc.ServerStaticAddressLoopInRequest.change_output:type_name -> looprpc.StaticAddressChangeOutput + 12, // 10: looprpc.ServerStaticAddressLoopInResponse.standard_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 12, // 11: looprpc.ServerStaticAddressLoopInResponse.high_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 12, // 12: looprpc.ServerStaticAddressLoopInResponse.extreme_fee_htlc_info:type_name -> looprpc.ServerHtlcSigningInfo + 14, // 13: looprpc.PushStaticAddressHtlcSigsRequest.standard_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 14, // 14: looprpc.PushStaticAddressHtlcSigsRequest.high_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 14, // 15: looprpc.PushStaticAddressHtlcSigsRequest.extreme_fee_htlc_info:type_name -> looprpc.ClientHtlcSigningInfo + 23, // 16: looprpc.PushStaticAddressSweeplessSigsRequest.signing_info:type_name -> looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry + 8, // 17: looprpc.ServerPsbtWithdrawResponse.SigningInfoEntry.value:type_name -> looprpc.ServerPsbtWithdrawSigningInfo + 17, // 18: looprpc.PushStaticAddressSweeplessSigsRequest.SigningInfoEntry.value:type_name -> looprpc.ClientSweeplessSigningInfo + 1, // 19: looprpc.StaticAddressServer.ServerNewAddress:input_type -> looprpc.ServerNewAddressRequest + 4, // 20: looprpc.StaticAddressServer.ServerWithdrawDeposits:input_type -> looprpc.ServerWithdrawRequest + 6, // 21: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:input_type -> looprpc.ServerPsbtWithdrawRequest + 10, // 22: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:input_type -> looprpc.ServerStaticAddressLoopInRequest + 13, // 23: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:input_type -> looprpc.PushStaticAddressHtlcSigsRequest + 16, // 24: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:input_type -> looprpc.PushStaticAddressSweeplessSigsRequest + 2, // 25: looprpc.StaticAddressServer.ServerNewAddress:output_type -> looprpc.ServerNewAddressResponse + 5, // 26: looprpc.StaticAddressServer.ServerWithdrawDeposits:output_type -> looprpc.ServerWithdrawResponse + 7, // 27: looprpc.StaticAddressServer.ServerPsbtWithdrawDeposits:output_type -> looprpc.ServerPsbtWithdrawResponse + 11, // 28: looprpc.StaticAddressServer.ServerStaticAddressLoopIn:output_type -> looprpc.ServerStaticAddressLoopInResponse + 15, // 29: looprpc.StaticAddressServer.PushStaticAddressHtlcSigs:output_type -> looprpc.PushStaticAddressHtlcSigsResponse + 18, // 30: looprpc.StaticAddressServer.PushStaticAddressSweeplessSigs:output_type -> looprpc.PushStaticAddressSweeplessSigsResponse + 25, // [25:31] is the sub-list for method output_type + 19, // [19:25] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_staticaddr_proto_init() } @@ -1339,7 +1465,7 @@ func file_staticaddr_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_staticaddr_proto_rawDesc), len(file_staticaddr_proto_rawDesc)), NumEnums: 1, - NumMessages: 20, + NumMessages: 23, NumExtensions: 0, NumServices: 1, }, diff --git a/swapserverrpc/staticaddr.proto b/swapserverrpc/staticaddr.proto index b590f4d44..0ae80edfd 100644 --- a/swapserverrpc/staticaddr.proto +++ b/swapserverrpc/staticaddr.proto @@ -124,6 +124,15 @@ message ServerPsbtWithdrawRequest { // The map of deposit txid:idx to the nonce used by the client. map deposit_to_nonces = 2; + + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + map deposit_to_client_pubkeys = 3; + + // Optional change output metadata for withdrawals that return funds to a + // newly generated static address. + StaticAddressChangeOutput change_output = 4; } message ServerPsbtWithdrawResponse { @@ -143,6 +152,17 @@ message ServerPsbtWithdrawSigningInfo { bytes sig = 2; } +message StaticAddressChangeOutput { + // The client static address pubkey used to derive the change output. + bytes client_pubkey = 1; + + // The expected output script for the static address change output. + bytes pk_script = 2; + + // The expected change amount in satoshis. + int64 amount = 3; +} + message ServerStaticAddressLoopInRequest { // The client's public key for the htlc output. bytes htlc_client_pub_key = 1; @@ -196,6 +216,15 @@ message ServerStaticAddressLoopInRequest { // If set, request the server to use fast publication behavior for this // swap. bool fast = 10; + + // The map of deposit txid:idx to the client static address pubkey that + // was used to derive the deposit output. The server combines this key + // with the L402's server pubkey and expiry to validate each input. + map deposit_to_client_pubkeys = 11; + + // Optional change output metadata for fractional loop-ins that return + // funds to a newly generated static address. + StaticAddressChangeOutput change_output = 12; } message ServerStaticAddressLoopInResponse { From f5ddd9872e62f9551b55f9bd4a9f95e6c2d1ad87 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:33 +0200 Subject: [PATCH 29/35] 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. --- staticaddr/loopin/actions.go | 29 ++++++++---- staticaddr/loopin/actions_test.go | 21 ++++++++ staticaddr/loopin/loopin.go | 19 ++++++++ staticaddr/loopin/sign_musig_test.go | 71 ++++++++++++++++++++++++++++ staticaddr/staticutil/utils.go | 33 +++++++++++++ staticaddr/staticutil/utils_test.go | 71 ++++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 staticaddr/loopin/sign_musig_test.go diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 608ddb1ae..4ddafbf0c 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -155,16 +155,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 224872ae4..8a0365a9a 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -755,6 +755,7 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { t.Parallel() mockLnd := test.NewMockLnd() + _, clientPubkey := test.CreateKey(20) _, serverKey := test.CreateKey(21) server := &mockStaticAddressServer{ @@ -769,6 +770,9 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { Index: 0, }, Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: clientPubkey, + }, } loopIn := &StaticAddressLoopIn{ @@ -802,6 +806,13 @@ 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()], + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, @@ -2853,10 +2864,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(), @@ -2903,12 +2919,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 25bd004af..d8710b1c1 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 000000000..c3915f8ba --- /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 17da06569..1401d58f7 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -53,6 +53,39 @@ func ToPrevOuts(deposits []*deposit.Deposit) ( return prevOuts, nil } +// DepositClientPubkeys maps each deposit outpoint to the client static address +// pubkey 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][]byte, error) { + + clientPubkeys := make(map[string][]byte, 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) + } + + depositKey := d.String() + if _, ok := clientPubkeys[depositKey]; ok { + return nil, fmt.Errorf("duplicate outpoint %v", + depositKey) + } + + clientPubkeys[depositKey] = + d.AddressParams.ClientPubkey.SerializeCompressed() + } + + 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 4f32eadea..9449edaec 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -141,6 +141,77 @@ 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(), + }, + } + d2 := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"), + Index: 1, + }, + AddressParams: &address.Parameters{ + ClientPubkey: clientKey2.PubKey(), + }, + } + + proofs, err := DepositClientPubkeys([]*deposit.Deposit{d1, d2}) + require.NoError(t, err) + require.Equal( + t, clientKey1.PubKey().SerializeCompressed(), + proofs[d1.String()], + ) + require.Equal( + t, clientKey2.PubKey().SerializeCompressed(), + proofs[d2.String()], + ) +} + +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(), + }, + } + _, err = DepositClientPubkeys([]*deposit.Deposit{d, d}) + require.ErrorContains(t, err, "duplicate outpoint") + }) +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { From a0f0374523fcbcfd19f4472a7b748418aa3aeaf3 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:33 +0200 Subject: [PATCH 30/35] staticaddr/withdraw: send per-deposit address proofs Include the derivation key for every withdrawal input in the server request. This lets the server validate and sign withdrawals that combine deposits from multiple derived addresses. --- staticaddr/withdraw/manager.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 002ee25c1..e113f7949 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -561,6 +561,12 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, return nil, nil, err } + depositClientPubkeys, err := staticutil.DepositClientPubkeys(deposits) + if err != nil { + return nil, nil, fmt.Errorf("unable to prepare static address "+ + "input proofs: %w", err) + } + withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( ctx, outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, @@ -579,8 +585,9 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, // nolint:lll sigResp, err := m.cfg.StaticAddressServerClient.ServerPsbtWithdrawDeposits( ctx, &staticaddressrpc.ServerPsbtWithdrawRequest{ - WithdrawalPsbt: unsignedPsbt, - DepositToNonces: clientNonces, + WithdrawalPsbt: unsignedPsbt, + DepositToNonces: clientNonces, + DepositToClientPubkeys: depositClientPubkeys, }, ) if err != nil { From 4c9d6af1d13d4c24db45c85eafeda42e3ef2da68 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 6 May 2026 15:02:09 +0200 Subject: [PATCH 31/35] staticaddr/deposit: restore owning address parameters Join each selected deposit with its persisted static-address row during loop-in recovery. Hydrate legacy rows as needed so restored swaps retain the scripts and key locators required for signing. --- cmd/loop/staticaddr_test.go | 7 +- loopdb/sqlc/queries/static_address_loopin.sql | 9 + loopdb/sqlc/static_address_loopin.sql.go | 25 +++ staticaddr/deposit/manager.go | 95 +++++++++- staticaddr/deposit/manager_reconcile_test.go | 29 ++- staticaddr/deposit/manager_test.go | 173 +++++++++++++++--- staticaddr/loopin/manager.go | 29 ++- staticaddr/loopin/manager_test.go | 15 +- staticaddr/loopin/sql_store.go | 10 + 9 files changed, 338 insertions(+), 54 deletions(-) diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2cc88ad66..2f7bcfb84 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/stretchr/testify/require" @@ -196,6 +197,9 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(fixture.value), ConfirmationHeight: fixture.confirmationHeight, + AddressParams: &address.Parameters{ + Expiry: csvExpiry, + }, }) } @@ -204,8 +208,7 @@ func TestWarningDepositSelectionMatchesLoopInSelection(t *testing.T) { ) loopInSelected, err := loopin.SelectDeposits( - btcutil.Amount(targetAmount), loopInDeposits, csvExpiry, - blockHeight, + btcutil.Amount(targetAmount), loopInDeposits, blockHeight, ) require.NoError(t, err) diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index b4fca5d45..4a88c5189 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -147,10 +147,19 @@ WHERE -- name: DepositsForSwapHash :many SELECT d.*, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index f6c896ce6..8cb2aef6d 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -46,10 +46,19 @@ func (q *Queries) DepositIDsForSwapHash(ctx context.Context, swapHash []byte) ([ const depositsForSwapHash = `-- name: DepositsForSwapHash :many SELECT d.id, d.deposit_id, d.tx_hash, d.out_index, d.amount, d.confirmation_height, d.timeout_sweep_pk_script, d.expiry_sweep_txid, d.finalized_withdrawal_tx, d.swap_hash, d.static_address_id, + sa.client_pubkey client_pubkey, + sa.server_pubkey server_pubkey, + sa.expiry expiry, + sa.client_key_family client_key_family, + sa.client_key_index client_key_index, + sa.pkscript pkscript, + sa.protocol_version protocol_version, + sa.initiation_height initiation_height, u.update_state, u.update_timestamp FROM deposits d + LEFT JOIN static_addresses sa ON sa.id = d.static_address_id LEFT JOIN deposit_updates u ON u.id = ( SELECT id @@ -74,6 +83,14 @@ type DepositsForSwapHashRow struct { FinalizedWithdrawalTx sql.NullString SwapHash []byte StaticAddressID sql.NullInt32 + ClientPubkey []byte + ServerPubkey []byte + Expiry sql.NullInt32 + ClientKeyFamily sql.NullInt32 + ClientKeyIndex sql.NullInt32 + Pkscript []byte + ProtocolVersion sql.NullInt32 + InitiationHeight sql.NullInt32 UpdateState sql.NullString UpdateTimestamp sql.NullTime } @@ -99,6 +116,14 @@ func (q *Queries) DepositsForSwapHash(ctx context.Context, swapHash []byte) ([]D &i.FinalizedWithdrawalTx, &i.SwapHash, &i.StaticAddressID, + &i.ClientPubkey, + &i.ServerPubkey, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.Pkscript, + &i.ProtocolVersion, + &i.InitiationHeight, &i.UpdateState, &i.UpdateTimestamp, ); err != nil { diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 3c627e29a..5bb3106c3 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -69,8 +70,8 @@ type Manager struct { // mu guards access to the activeDeposits map. mu sync.Mutex - // reconcileMu serializes deposit reconciliation so new deposits are - // discovered and retained exactly once per outpoint. + // reconcileMu serializes startup recovery and deposit reconciliation so + // new deposits are discovered and retained exactly once per outpoint. reconcileMu sync.Mutex // activeDeposits contains all the active static address outputs. @@ -213,6 +214,9 @@ func (m *Manager) notifyActiveDeposits(ctx context.Context, // recoverDeposits recovers static address parameters, previous deposits and // state machines from the database and starts the deposit notifier. func (m *Manager) recoverDeposits(ctx context.Context) error { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + log.Infof("Recovering static address parameters and deposits...") // Recover deposits. @@ -222,6 +226,11 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { } for i, d := range deposits { + err = m.hydrateLegacyDepositAddressParams(ctx, d) + if err != nil { + return err + } + m.deposits[d.OutPoint] = deposits[i] // If the current deposit is final it wasn't active when we @@ -258,6 +267,66 @@ func (m *Manager) recoverDeposits(ctx context.Context) error { return nil } +// hydrateLegacyDepositAddressParams fills in address parameters for deposits +// that predate the durable deposit-to-static-address link. Those deposits all +// belonged to the legacy/root static address, so the legacy address manager +// lookup preserves the behavior that existed before multi-address support. +func (m *Manager) hydrateLegacyDepositAddressParams(ctx context.Context, + deposits ...*Deposit) error { + + needsHydration := false + for _, d := range deposits { + if d != nil && d.AddressParams == nil { + needsHydration = true + break + } + } + if !needsHydration { + return nil + } + + if m.cfg == nil || m.cfg.AddressManager == nil { + return nil + } + + var legacyParams *address.Parameters + for _, d := range deposits { + if d == nil || d.AddressParams != nil { + continue + } + + if legacyParams == nil { + params, err := m.cfg.AddressManager. + GetStaticAddressParameters(ctx) + if err != nil { + return fmt.Errorf("unable to load legacy "+ + "static address parameters for deposit %v: %w", + d.OutPoint, err) + } + if params == nil { + return fmt.Errorf("missing legacy static address "+ + "parameters for deposit %v", d.OutPoint) + } + + if params.ID <= 0 { + params.ID, err = m.cfg.AddressManager. + GetStaticAddressID(ctx, params.PkScript) + if err != nil { + return fmt.Errorf("unable to load legacy "+ + "static address ID for deposit %v: %w", + d.OutPoint, err) + } + } + + legacyParams = params + } + + d.AddressParams = legacyParams + } + + return nil +} + // pollDeposits periodically polls for new deposits to our static address. This // complements the block-driven reconciliation in the main event loop: while new // blocks trigger reconcileDeposits to promptly detect confirmations, the ticker @@ -796,7 +865,17 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) { // GetAllDeposits returns all known deposits from the database. func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) { - return m.cfg.Store.AllDeposits(ctx) + deposits, err := m.cfg.Store.AllDeposits(ctx) + if err != nil { + return nil, err + } + + err = m.hydrateLegacyDepositAddressParams(ctx, deposits...) + if err != nil { + return nil, err + } + + return deposits, nil } // GetVisibleDeposits returns deposits that should be exposed through normal @@ -811,6 +890,11 @@ func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) { return nil, err } + err = m.hydrateLegacyDepositAddressParams(ctx, deposits...) + if err != nil { + return nil, err + } + m.mu.Lock() defer m.mu.Unlock() @@ -887,6 +971,11 @@ func (m *Manager) DepositsForOutpoints(ctx context.Context, return nil, err } + err = m.hydrateLegacyDepositAddressParams(ctx, deposit) + if err != nil { + return nil, err + } + deposits = append(deposits, deposit) } diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index dff337d63..3ab9a56bf 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/test" @@ -41,8 +42,15 @@ func TestReconcileDepositsSerialized(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) mockStore := new(mockStore) var createCalls atomic.Int32 @@ -137,8 +145,15 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), ).Return([]*lnwallet.Utxo{utxo}, nil) mockAddressManager.On( - "GetStaticAddressParameters", mock.Anything, - ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) + "GetParameters", mock.Anything, + ).Return(&address.Parameters{ + ID: 1, + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: utxo.PkScript, + ProtocolVersion: 999, + }) mockStore := new(mockStore) mockStore.On( @@ -471,6 +486,12 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { OutPoint: outpoint, Value: btcutil.Amount(100_000), ConfirmationHeight: 77, + AddressParams: &address.Parameters{ + ClientPubkey: defaultServerPubkey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + ProtocolVersion: version.ProtocolVersion_V0, + }, } deposit.SetState(Deposited) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index d2d78a5c6..f386dd72d 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -12,6 +12,7 @@ import ( "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/script" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" @@ -523,6 +524,96 @@ func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { } } +func TestRecoverDepositsKeepsSpentWithdrawing(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + state: Withdrawing, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + + deposits, err := testContext.manager.GetActiveDepositsInState(Withdrawing) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.Equal(t, storedDeposit.OutPoint, deposits[0].OutPoint) +} + +func TestRecoverDepositsHydratesLegacyAddressParams(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + }, + state: Deposited, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69}, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + storedDeposit.AddressParams = nil + + err = testContext.manager.recoverDeposits(ctx) + require.NoError(t, err) + require.NotNil(t, storedDeposit.AddressParams) + require.NotZero(t, storedDeposit.AddressParams.ID) +} + +func TestGetAllDepositsHydratesLegacyAddressParams(t *testing.T) { + ctx := context.Background() + + id, err := GetRandomDepositID() + require.NoError(t, err) + + storedDeposit := &Deposit{ + ID: id, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 4, + }, + state: Withdrawn, + Value: btcutil.Amount(100000), + ConfirmationHeight: 42, + } + + testContext := newManagerTestContextWithStoredDeposits( + t, []*Deposit{storedDeposit}, nil, + ) + + storedDeposit.AddressParams = nil + + deposits, err := testContext.manager.GetAllDeposits(ctx) + require.NoError(t, err) + require.Len(t, deposits, 1) + require.NotNil(t, deposits[0].AddressParams) + require.NotZero(t, deposits[0].AddressParams.ID) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -539,19 +630,9 @@ type ManagerTestContext struct { // newManagerTestContext creates a new test context for the reservation manager. func newManagerTestContext(t *testing.T) *ManagerTestContext { - mockLnd := test.NewMockLnd() - lndContext := test.NewContext(t, mockLnd) - - mockStaticAddressClient := new(mockStaticAddressClient) - mockAddressManager := new(mockAddressManager) - mockStore := new(mockStore) - mockChainNotifier := new(MockChainNotifier) - confChan := make(chan *chainntnfs.TxConfirmation) - confErrChan := make(chan error) - blockChan := make(chan int32) - blockErrChan := make(chan error) - ID, err := GetRandomDepositID() + require.NoError(t, err) + utxo := &lnwallet.Utxo{ AddressType: lnwallet.TaprootPubkey, Value: btcutil.Amount(100000), @@ -562,7 +643,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + storedDeposits := []*Deposit{ { ID: ID, @@ -574,6 +655,26 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { }, } + return newManagerTestContextWithStoredDeposits( + t, storedDeposits, []*lnwallet.Utxo{utxo}, + ) +} + +func newManagerTestContextWithStoredDeposits(t *testing.T, + storedDeposits []*Deposit, utxos []*lnwallet.Utxo) *ManagerTestContext { + + mockLnd := test.NewMockLnd() + lndContext := test.NewContext(t, mockLnd) + + mockStaticAddressClient := new(mockStaticAddressClient) + mockAddressManager := new(mockAddressManager) + mockStore := new(mockStore) + mockChainNotifier := new(MockChainNotifier) + confChan := make(chan *chainntnfs.TxConfirmation) + confErrChan := make(chan error) + blockChan := make(chan int32) + blockErrChan := make(chan error) + mockStore.On( "AllDeposits", mock.Anything, ).Return(storedDeposits, nil) @@ -582,17 +683,29 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { "UpdateDeposit", mock.Anything, mock.Anything, ).Return(nil) + staticAddress, addrParams := generateStaticAddress( + context.Background(), mockLnd, lndContext.T, + ) + for _, storedDeposit := range storedDeposits { + if storedDeposit.AddressParams == nil { + storedDeposit.AddressParams = addrParams + } + } + var manager *Manager + mockAddressManager.On( "GetStaticAddressParameters", mock.Anything, - ).Return(&script.Parameters{ - Expiry: defaultExpiry, - }, nil) + ).Return(addrParams, nil) mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, ).Return(func() []*lnwallet.Utxo { - currentUtxo := *utxo + if len(utxos) != 1 { + return utxos + } + + currentUtxo := *utxos[0] currentHeight := manager.currentHeight.Load() if currentHeight < defaultDepositConfirmations { currentUtxo.Confirmations = 0 @@ -637,9 +750,6 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { blockErrChan: blockErrChan, } - staticAddress := generateStaticAddress( - context.Background(), testContext, - ) mockAddressManager.On( "GetStaticAddress", mock.Anything, ).Return(staticAddress, nil) @@ -647,19 +757,30 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { return testContext } -func generateStaticAddress(ctx context.Context, - t *ManagerTestContext) *script.StaticAddress { +func generateStaticAddress(ctx context.Context, mockLnd *test.LndMockServices, + t *testing.T) (*script.StaticAddress, *address.Parameters) { - keyDescriptor, err := t.mockLnd.WalletKit.DeriveNextKey( + keyDescriptor, err := mockLnd.WalletKit.DeriveNextKey( ctx, swap.StaticAddressKeyFamily, ) - require.NoError(t.context.T, err) + require.NoError(t, err) staticAddress, err := script.NewStaticAddress( input.MuSig2Version100RC2, int64(defaultExpiry), keyDescriptor.PubKey, defaultServerPubkey, ) - require.NoError(t.context.T, err) + require.NoError(t, err) - return staticAddress + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return staticAddress, &address.Parameters{ + ID: 1, + ClientPubkey: keyDescriptor.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDescriptor.KeyLocator, + ProtocolVersion: 0, + } } diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 0772252a0..b959e9030 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -675,19 +675,8 @@ func (m *Manager) initiateLoopIn(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - selectedDeposits, err = SelectDeposits( - req.SelectedAmount, allDeposits, params.Expiry, - m.currentHeight.Load(), + req.SelectedAmount, allDeposits, m.currentHeight.Load(), ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", @@ -867,15 +856,21 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( // leaving a dust change. It returns an error if the sum of deposits minus dust // is less than the requested amount. func SelectDeposits(targetAmount btcutil.Amount, - unfilteredDeposits []*deposit.Deposit, csvExpiry uint32, - blockHeight uint32) ([]*deposit.Deposit, error) { + unfilteredDeposits []*deposit.Deposit, blockHeight uint32) ( + []*deposit.Deposit, error) { // Filter out deposits that are too close to expiry to be swapped. var deposits []*deposit.Deposit for _, d := range unfilteredDeposits { confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static address parameters "+ + "for deposit %s", d.OutPoint.String()) + } + if !IsSwappable( - uint32(confirmationHeight), blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, + d.AddressParams.Expiry, ) { log.Debugf("Skipping deposit %s as it expires before "+ @@ -902,11 +897,11 @@ func SelectDeposits(targetAmount btcutil.Amount, if deposits[i].Value == deposits[j].Value { iExp := blocksUntilDepositExpiry( uint32(iConfirmationHeight), blockHeight, - csvExpiry, + deposits[i].AddressParams.Expiry, ) jExp := blocksUntilDepositExpiry( uint32(jConfirmationHeight), blockHeight, - csvExpiry, + deposits[j].AddressParams.Expiry, ) return iExp < jExp diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 564429c60..adbfe4436 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -14,6 +14,7 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" @@ -193,9 +194,11 @@ func TestSelectDeposits(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + setTestDepositParams(tc.deposits, tc.csvExpiry) + setTestDepositParams(tc.expected, tc.csvExpiry) + selectedDeposits, err := SelectDeposits( - tc.targetValue, tc.deposits, tc.csvExpiry, - tc.blockHeight, + tc.targetValue, tc.deposits, tc.blockHeight, ) if tc.expectedErr == "" { require.NoError(t, err) @@ -378,6 +381,14 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } +func setTestDepositParams(deposits []*deposit.Deposit, expiry uint32) { + for _, d := range deposits { + d.AddressParams = &address.Parameters{ + Expiry: expiry, + } + } +} + // TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered // swappable because its CSV timeout has not started yet. func TestIsSwappableUnconfirmed(t *testing.T) { diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index e5af47ca2..4accd9080 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -587,6 +587,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, TimeoutSweepPkScript: d.TimeoutSweepPkScript, ExpirySweepTxid: d.ExpirySweepTxid, FinalizedWithdrawalTx: d.FinalizedWithdrawalTx, + SwapHash: d.SwapHash, + StaticAddressID: d.StaticAddressID, + ClientPubkey: d.ClientPubkey, + ServerPubkey: d.ServerPubkey, + Expiry: d.Expiry, + ClientKeyFamily: d.ClientKeyFamily, + ClientKeyIndex: d.ClientKeyIndex, + Pkscript: d.Pkscript, + ProtocolVersion: d.ProtocolVersion, + InitiationHeight: d.InitiationHeight, } sqlcDepositUpdate := sqlc.DepositUpdate{ From 4235437d9200c648b34589e5d5f659dede04ac9e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:57 +0200 Subject: [PATCH 32/35] staticaddr/loopin: use generated change addresses Create a fresh static change address for fractional loop-ins and persist its key locator with the selected HTLC outpoint. Recovery reconstructs the same change output instead of returning funds to the legacy root address. --- ...0023_static_loopin_change_address.down.sql | 1 + ...000023_static_loopin_change_address.up.sql | 13 ++ ...00024_static_loopin_htlc_outpoint.down.sql | 8 + .../000024_static_loopin_htlc_outpoint.up.sql | 8 + loopdb/sqlc/models.go | 4 + loopdb/sqlc/queries/static_address_loopin.sql | 39 ++++- loopdb/sqlc/static_address_loopin.sql.go | 109 ++++++++++-- staticaddr/loopin/actions.go | 81 ++++++++- staticaddr/loopin/actions_test.go | 155 +++++++++++++++++- staticaddr/loopin/interface.go | 5 + staticaddr/loopin/loopin.go | 121 +++++++++++--- staticaddr/loopin/loopin_test.go | 76 ++++++++- staticaddr/loopin/manager.go | 70 +++++--- staticaddr/loopin/manager_test.go | 45 +++-- staticaddr/loopin/sql_store.go | 90 +++++++++- staticaddr/loopin/sql_store_test.go | 64 ++++++++ staticaddr/staticutil/utils.go | 28 ++++ staticaddr/staticutil/utils_test.go | 39 +++++ 18 files changed, 866 insertions(+), 90 deletions(-) create mode 100644 loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql create mode 100644 loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql create mode 100644 loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql create mode 100644 loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql new file mode 100644 index 000000000..8a0180293 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.down.sql @@ -0,0 +1 @@ +ALTER TABLE static_address_swaps DROP COLUMN change_static_address_id; diff --git a/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql new file mode 100644 index 000000000..6383bf766 --- /dev/null +++ b/loopdb/sqlc/migrations/000023_static_loopin_change_address.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE static_address_swaps + ADD change_static_address_id INT REFERENCES static_addresses(id); + +-- Existing fractional swaps sent change back to the legacy static address. +-- Backfill that relation so in-flight swaps remain recoverable after the +-- client starts requiring explicit per-swap change metadata. +UPDATE static_address_swaps +SET change_static_address_id = ( + SELECT id FROM static_addresses ORDER BY id ASC LIMIT 1 +) +WHERE selected_amount > 0 + AND change_static_address_id IS NULL + AND EXISTS (SELECT 1 FROM static_addresses); diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql new file mode 100644 index 000000000..caaf9571a --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.down.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_value; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_output_index; + +ALTER TABLE static_address_swaps + DROP COLUMN confirmed_htlc_tx_id; diff --git a/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql new file mode 100644 index 000000000..1af35c637 --- /dev/null +++ b/loopdb/sqlc/migrations/000024_static_loopin_htlc_outpoint.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE static_address_swaps + ADD confirmed_htlc_tx_id TEXT; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_index INTEGER; + +ALTER TABLE static_address_swaps + ADD confirmed_htlc_output_value BIGINT; diff --git a/loopdb/sqlc/models.go b/loopdb/sqlc/models.go index 34a924256..94e9cf1eb 100644 --- a/loopdb/sqlc/models.go +++ b/loopdb/sqlc/models.go @@ -152,6 +152,10 @@ type StaticAddressSwap struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } type StaticAddressSwapUpdate struct { diff --git a/loopdb/sqlc/queries/static_address_loopin.sql b/loopdb/sqlc/queries/static_address_loopin.sql index 4a88c5189..ce40bc577 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -10,7 +10,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -22,14 +23,18 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ); -- name: UpdateStaticAddressLoopIn :exec UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 WHERE swap_hash = $1; @@ -64,13 +69,24 @@ INSERT INTO static_address_swap_updates ( SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1; @@ -78,13 +94,24 @@ WHERE SELECT swaps.*, static_address_swaps.*, - htlc_keys.* + htlc_keys.*, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -170,5 +197,3 @@ FROM ) WHERE d.swap_hash = $1; - - diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 8cb2aef6d..bc17bec74 100644 --- a/loopdb/sqlc/static_address_loopin.sql.go +++ b/loopdb/sqlc/static_address_loopin.sql.go @@ -180,14 +180,25 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([] const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, - htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, + htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id WHERE swaps.swap_hash = $1 ` @@ -218,6 +229,10 @@ type GetStaticAddressLoopInSwapRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -225,6 +240,14 @@ type GetStaticAddressLoopInSwapRow struct { ReceiverInternalPubkey []byte ClientKeyFamily int32 ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) { @@ -256,6 +279,10 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -263,6 +290,14 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ) return i, err } @@ -270,14 +305,25 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many SELECT swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label, - static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, - htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index + static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time, static_address_swaps.change_static_address_id, static_address_swaps.confirmed_htlc_tx_id, static_address_swaps.confirmed_htlc_output_index, static_address_swaps.confirmed_htlc_output_value, + htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index, + change_address.client_pubkey change_client_pubkey, + change_address.server_pubkey change_server_pubkey, + change_address.expiry change_expiry, + change_address.client_key_family change_client_key_family, + change_address.client_key_index change_client_key_index, + change_address.pkscript change_pkscript, + change_address.protocol_version change_protocol_version, + change_address.initiation_height change_initiation_height FROM swaps JOIN static_address_swaps ON swaps.swap_hash = static_address_swaps.swap_hash JOIN htlc_keys ON swaps.swap_hash = htlc_keys.swap_hash + LEFT JOIN + static_addresses change_address + ON static_address_swaps.change_static_address_id = change_address.id JOIN static_address_swap_updates u ON swaps.swap_hash = u.swap_hash -- This subquery ensures that we are checking only the latest update for @@ -319,6 +365,10 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { Fast bool ConfirmationRiskDecision string ConfirmationRiskDecisionTime sql.NullTime + ChangeStaticAddressID sql.NullInt32 + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 SwapHash_3 []byte SenderScriptPubkey []byte ReceiverScriptPubkey []byte @@ -326,6 +376,14 @@ type GetStaticAddressLoopInSwapsByStatesRow struct { ReceiverInternalPubkey []byte ClientKeyFamily int32 ClientKeyIndex int32 + ChangeClientPubkey []byte + ChangeServerPubkey []byte + ChangeExpiry sql.NullInt32 + ChangeClientKeyFamily sql.NullInt32 + ChangeClientKeyIndex sql.NullInt32 + ChangePkscript []byte + ChangeProtocolVersion sql.NullInt32 + ChangeInitiationHeight sql.NullInt32 } func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) { @@ -363,6 +421,10 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.Fast, &i.ConfirmationRiskDecision, &i.ConfirmationRiskDecisionTime, + &i.ChangeStaticAddressID, + &i.ConfirmedHtlcTxID, + &i.ConfirmedHtlcOutputIndex, + &i.ConfirmedHtlcOutputValue, &i.SwapHash_3, &i.SenderScriptPubkey, &i.ReceiverScriptPubkey, @@ -370,6 +432,14 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla &i.ReceiverInternalPubkey, &i.ClientKeyFamily, &i.ClientKeyIndex, + &i.ChangeClientPubkey, + &i.ChangeServerPubkey, + &i.ChangeExpiry, + &i.ChangeClientKeyFamily, + &i.ChangeClientKeyIndex, + &i.ChangePkscript, + &i.ChangeProtocolVersion, + &i.ChangeInitiationHeight, ); err != nil { return nil, err } @@ -396,7 +466,8 @@ INSERT INTO static_address_swaps ( htlc_tx_fee_rate_sat_kw, htlc_timeout_sweep_tx_id, htlc_timeout_sweep_address, - fast + fast, + change_static_address_id ) VALUES ( $1, $2, @@ -408,7 +479,8 @@ INSERT INTO static_address_swaps ( $8, $9, $10, - $11 + $11, + $12 ) ` @@ -424,6 +496,7 @@ type InsertStaticAddressLoopInParams struct { HtlcTimeoutSweepTxID sql.NullString HtlcTimeoutSweepAddress string Fast bool + ChangeStaticAddressID sql.NullInt32 } func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStaticAddressLoopInParams) error { @@ -439,6 +512,7 @@ func (q *Queries) InsertStaticAddressLoopIn(ctx context.Context, arg InsertStati arg.HtlcTimeoutSweepTxID, arg.HtlcTimeoutSweepAddress, arg.Fast, + arg.ChangeStaticAddressID, ) return err } @@ -565,18 +639,31 @@ const updateStaticAddressLoopIn = `-- name: UpdateStaticAddressLoopIn :exec UPDATE static_address_swaps SET htlc_tx_fee_rate_sat_kw = $2, - htlc_timeout_sweep_tx_id = $3 + htlc_timeout_sweep_tx_id = $3, + confirmed_htlc_tx_id = $4, + confirmed_htlc_output_index = $5, + confirmed_htlc_output_value = $6 WHERE swap_hash = $1 ` type UpdateStaticAddressLoopInParams struct { - SwapHash []byte - HtlcTxFeeRateSatKw int64 - HtlcTimeoutSweepTxID sql.NullString + SwapHash []byte + HtlcTxFeeRateSatKw int64 + HtlcTimeoutSweepTxID sql.NullString + ConfirmedHtlcTxID sql.NullString + ConfirmedHtlcOutputIndex sql.NullInt32 + ConfirmedHtlcOutputValue sql.NullInt64 } func (q *Queries) UpdateStaticAddressLoopIn(ctx context.Context, arg UpdateStaticAddressLoopInParams) error { - _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, arg.SwapHash, arg.HtlcTxFeeRateSatKw, arg.HtlcTimeoutSweepTxID) + _, err := q.db.ExecContext(ctx, updateStaticAddressLoopIn, + arg.SwapHash, + arg.HtlcTxFeeRateSatKw, + arg.HtlcTimeoutSweepTxID, + arg.ConfirmedHtlcTxID, + arg.ConfirmedHtlcOutputIndex, + arg.ConfirmedHtlcOutputValue, + ) return err } diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 4ddafbf0c..18213c4fb 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -106,6 +107,29 @@ func (f *FSM) InitHtlcAction(ctx context.Context, } swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee + var changeOutput *swapserverrpc.StaticAddressChangeOutput + if hasChange { + changeAmount := f.loopIn.ExpectedChangeAmount() + f.loopIn.ChangeAddressParams, err = + f.cfg.AddressManager.NewChangeAddress(ctx) + if err != nil { + err = fmt.Errorf("unable to create static address "+ + "change output: %w", err) + + return returnError(err) + } + + changeOutput, err = staticutil.ChangeOutput( + f.loopIn.ChangeAddressParams, changeAmount, + ) + if err != nil { + err = fmt.Errorf("unable to prepare static address "+ + "change output: %w", err) + + return returnError(err) + } + } + // Generate random preimage. var swapPreimage lntypes.Preimage if _, err = rand.Read(swapPreimage[:]); err != nil { @@ -176,6 +200,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context, PaymentTimeoutSeconds: f.loopIn.PaymentTimeoutSeconds, Fast: f.loopIn.Fast, DepositToClientPubkeys: depositClientPubkeys, + ChangeOutput: changeOutput, } if f.loopIn.LastHop != nil { loopInReq.LastHop = f.loopIn.LastHop @@ -1108,9 +1133,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed := false for { select { - case <-htlcConfChan: + case conf := <-htlcConfChan: f.Infof("htlc tx confirmed") + err = f.recordConfirmedHtlc(ctx, conf, htlc.PkScript) + if err != nil { + return f.HandleError(err) + } + htlcConfirmed = true if invoiceCanceledForNonPayment { transitionDepositsToHtlcTimeout( @@ -1148,6 +1178,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // confirmation and re-register for the next // confirmation. htlcConfirmed = false + err = f.clearConfirmedHtlc(ctx) + if err != nil { + return f.HandleError(err) + } htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { @@ -1316,6 +1350,51 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } } +func (f *FSM) recordConfirmedHtlc(ctx context.Context, + conf *chainntnfs.TxConfirmation, htlcPkScript []byte) error { + + if conf == nil || conf.Tx == nil { + return errors.New("htlc confirmation missing transaction") + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + tx := conf.Tx + txHash := tx.TxHash() + for idx, txOut := range tx.TxOut { + if !bytes.Equal(txOut.PkScript, htlcPkScript) { + continue + } + + f.loopIn.HtlcTxHash = &txHash + f.loopIn.HtlcOutputIndex = uint32(idx) + f.loopIn.HtlcOutputValue = btcutil.Amount(txOut.Value) + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) + } + + return fmt.Errorf("confirmed htlc tx %v missing expected htlc "+ + "output", txHash) +} + +func (f *FSM) clearConfirmedHtlc(ctx context.Context) error { + if f.loopIn.HtlcTxHash == nil && f.loopIn.HtlcOutputIndex == 0 && + f.loopIn.HtlcOutputValue == 0 { + + return nil + } + if f.cfg.Store == nil { + return errors.New("missing static address loop-in store") + } + + f.loopIn.HtlcTxHash = nil + f.loopIn.HtlcOutputIndex = 0 + f.loopIn.HtlcOutputValue = 0 + + return f.cfg.Store.UpdateLoopIn(ctx, f.loopIn) +} + // htlcTimeoutSweepRetryDelay is the delay between retries when publishing the // htlc timeout sweep transaction fails. const htlcTimeoutSweepRetryDelay = time.Hour diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 8a0365a9a..4b0142058 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -12,12 +12,14 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/zpay32" @@ -887,6 +889,78 @@ func TestCheckDepositsAvailableRejectsDivergentDepositOutpoints( require.Empty(t, checker.outpoints) } +// TestInitHtlcActionSendsChangeOutput asserts that fractional loop-ins create +// and send an operation-specific static change output to the server. +func TestInitHtlcActionSendsChangeOutput(t *testing.T) { + t.Parallel() + + mockLnd := test.NewMockLnd() + _, depositClientPubkey := test.CreateKey(31) + _, changeClientPubkey := test.CreateKey(32) + _, serverKey := test.CreateKey(33) + + server := &mockStaticAddressServer{ + response: testStaticAddressLoopInResponse( + serverKey.SerializeCompressed(), + ), + } + + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + Value: 500_000, + AddressParams: &address.Parameters{ + ClientPubkey: depositClientPubkey, + }, + } + changeParams := &address.Parameters{ + ID: 1, + ClientPubkey: changeClientPubkey, + PkScript: []byte{0x51, 0x20, 0x01}, + } + + loopIn := &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + DepositOutpoints: []string{dep.OutPoint.String()}, + SelectedAmount: 300_000, + QuotedSwapFee: 1_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + PaymentTimeoutSeconds: 3_600, + } + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + Server: server, + AddressManager: &mockAddressManager{params: changeParams}, + DepositManager: &noopDepositManager{}, + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + ChainParams: mockLnd.ChainParams, + Store: &mockStore{}, + ValidateLoopInContract: testValidateLoopInContract, + MaxStaticAddrHtlcFeePercentage: 1, + MaxStaticAddrHtlcBackupFeePercentage: 1, + }, + loopIn: loopIn, + } + + event := f.InitHtlcAction(t.Context(), nil) + require.Equal(t, OnHtlcInitiated, event) + require.Nil(t, f.LastActionError) + require.NotNil(t, server.request.ChangeOutput) + require.EqualValues(t, 200_000, server.request.ChangeOutput.Amount) + require.Equal( + t, changeClientPubkey.SerializeCompressed(), + server.request.ChangeOutput.ClientPubkey, + ) + require.Equal(t, changeParams.PkScript, server.request.ChangeOutput.PkScript) + require.Same(t, changeParams, loopIn.ChangeAddressParams) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient @@ -925,6 +999,70 @@ func testStaticAddressLoopInResponse( } } +type recordingLoopInStore struct { + mockStore + + updates []*StaticAddressLoopIn +} + +func (s *recordingLoopInStore) UpdateLoopIn(_ context.Context, + loopIn *StaticAddressLoopIn) error { + + s.updates = append(s.updates, loopIn) + + return nil +} + +// TestRecordConfirmedHtlcPersistsOutpoint verifies that the FSM records the +// exact confirmed server HTLC output before the timeout branch can sweep it. +func TestRecordConfirmedHtlcPersistsOutpoint(t *testing.T) { + t.Parallel() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{1, 2, 4}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + } + htlc, err := loopIn.getHtlc(test.NewMockLnd().ChainParams) + require.NoError(t, err) + + htlcValue := int64(123_456) + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: []byte{0x51}, + }) + tx.AddTxOut(&wire.TxOut{ + Value: htlcValue, + PkScript: htlc.PkScript, + }) + + store := &recordingLoopInStore{} + f := &FSM{ + cfg: &Config{Store: store}, + loopIn: loopIn, + } + + err = f.recordConfirmedHtlc( + t.Context(), &chainntnfs.TxConfirmation{Tx: tx}, + htlc.PkScript, + ) + require.NoError(t, err) + + txHash := tx.TxHash() + require.NotNil(t, loopIn.HtlcTxHash) + require.Equal(t, txHash, *loopIn.HtlcTxHash) + require.EqualValues(t, 1, loopIn.HtlcOutputIndex) + require.EqualValues(t, htlcValue, loopIn.HtlcOutputValue) + require.Len(t, store.updates, 1) +} + // testStaticAddressRouteHints returns deterministic route hints for static // loop-in invoice regression tests. func testStaticAddressRouteHints() [][]zpay32.HopHint { @@ -1033,10 +1171,18 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, NotificationManager: notificationMgr, + Store: &recordingLoopInStore{}, } f, err := NewFSM(ctx, loopIn, cfg, false) require.NoError(t, err) + htlc, err := loopIn.getHtlc(mockLnd.ChainParams) + require.NoError(t, err) + htlcTx := wire.NewMsgTx(2) + htlcTx.AddTxOut(&wire.TxOut{ + Value: 1, + PkScript: htlc.PkScript, + }) resultChan := make(chan fsm.EventType, 1) go func() { @@ -1055,7 +1201,7 @@ func TestMonitorInvoiceAndHtlcTxLocksConfirmedHtlcAtDeadline(t *testing.T) { case <-ctx.Done(): t.Fatalf("htlc conf registration not received: %v", ctx.Err()) } - confRegistration.ConfChan <- nil + confRegistration.ConfChan <- &chainntnfs.TxConfirmation{Tx: htlcTx} select { case hash := <-mockLnd.FailInvoiceChannel: @@ -3073,6 +3219,13 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( return nil, nil } +// NewChangeAddress returns configured parameters for tests that need change. +func (m *mockAddressManager) NewChangeAddress(_ context.Context) ( + *address.Parameters, error) { + + return m.params, nil +} + // noopDepositManager is a stub DepositManager used to satisfy FSM config. type noopDepositManager struct { deposits []*deposit.Deposit diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index d54355a7b..aa54e7277 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" @@ -41,6 +42,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) } // DepositManager handles the interaction of loop-ins with deposits. diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index d8710b1c1..bf3467f84 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -166,6 +167,11 @@ type StaticAddressLoopIn struct { // Address is the address script that is used for the swap. Address *script.StaticAddress + // ChangeAddressParams are the static address parameters for the change + // output that belongs to this swap. It is set only when SelectedAmount + // leaves non-dust change. + ChangeAddressParams *address.Parameters + // HTLC fields. // HtlcTxFeeRate is the fee rate that is used for the htlc transaction. @@ -182,6 +188,16 @@ type StaticAddressLoopIn struct { // HtlcTimeoutSweepTxHash is the hash of the htlc timeout sweep tx. HtlcTimeoutSweepTxHash *chainhash.Hash + // HtlcTxHash is the hash of the confirmed htlc tx published by the + // server. + HtlcTxHash *chainhash.Hash + + // HtlcOutputIndex is the output index of the confirmed htlc output. + HtlcOutputIndex uint32 + + // HtlcOutputValue is the value of the confirmed htlc output. + HtlcOutputValue btcutil.Amount + // HtlcTimeoutSweepAddress HtlcTimeoutSweepAddress btcutil.Address @@ -306,11 +322,10 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // change. var ( swapAmt = l.TotalDepositAmount() - changeAmount btcutil.Amount + changeAmount = l.ExpectedChangeAmount() ) if l.SelectedAmount > 0 { swapAmt = l.SelectedAmount - changeAmount = l.TotalDepositAmount() - l.SelectedAmount } // Calculate htlc tx fee for server provided fee rate. @@ -346,9 +361,14 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params, // We expect change to be sent back to our static address output script. if changeAmount > 0 { + if l.ChangeAddressParams == nil { + return nil, fmt.Errorf("missing static address change " + + "parameters") + } + msgTx.AddTxOut(&wire.TxOut{ Value: int64(changeAmount), - PkScript: l.AddressParams.PkScript, + PkScript: l.ChangeAddressParams.PkScript, }) } @@ -408,36 +428,18 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return nil, err } - htlcTx, err := l.createHtlcTx( - network, l.HtlcTxFeeRate, maxFeePercentage, + htlcOutpoint, htlcOutValue, err := l.confirmedHtlcOutpoint( + network, maxFeePercentage, ) if err != nil { return nil, err } - // The HTLC output is always at index 0 (createHtlcTx adds it first). - // If there is a change output, it is at index 1. Verify this invariant - // so we fail fast if createHtlcTx's layout ever changes. - const htlcInputIndex = uint32(0) - if len(htlcTx.TxOut) == 2 { - if bytes.Equal( - htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript, - ) { - - return nil, fmt.Errorf("htlc tx output layout " + - "invariant violated: expected HTLC output " + - "at index 0, got change output") - } - } - // Add the htlc input. sweepTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: htlcTx.TxHash(), - Index: htlcInputIndex, - }, - SignatureScript: htlc.SigScript, - Sequence: htlc.SuccessSequence(), + PreviousOutPoint: htlcOutpoint, + SignatureScript: htlc.SigScript, + Sequence: htlc.SuccessSequence(), }) // Add the sweep output. @@ -448,7 +450,6 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, fee := feeRate.FeeForWeight(weightEstimator.Weight()) - htlcOutValue := htlcTx.TxOut[htlcInputIndex].Value output := &wire.TxOut{ Value: htlcOutValue - int64(fee), PkScript: sweepPkScript, @@ -487,6 +488,56 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context, return sweepTx, nil } +// confirmedHtlcOutpoint returns the exact confirmed htlc outpoint when it has +// been persisted. Older loop-ins fall back to reconstructing the standard-fee +// htlc tx, which was the historical behavior before we stored the actual +// server-published variant. +func (l *StaticAddressLoopIn) confirmedHtlcOutpoint( + network *chaincfg.Params, maxFeePercentage float64) (wire.OutPoint, + int64, error) { + + if l.HtlcTxHash != nil { + if l.HtlcOutputValue <= 0 { + return wire.OutPoint{}, 0, fmt.Errorf("missing htlc "+ + "output value for confirmed htlc tx %v", + l.HtlcTxHash) + } + + return wire.OutPoint{ + Hash: *l.HtlcTxHash, + Index: l.HtlcOutputIndex, + }, int64(l.HtlcOutputValue), nil + } + + htlcTx, err := l.createHtlcTx( + network, l.HtlcTxFeeRate, maxFeePercentage, + ) + if err != nil { + return wire.OutPoint{}, 0, err + } + + // The HTLC output is always at index 0 (createHtlcTx adds it first). + // If there is a change output, it is at index 1. Verify this invariant + // so we fail fast if createHtlcTx's layout ever changes. + const htlcInputIndex = uint32(0) + if len(htlcTx.TxOut) == 2 && l.ChangeAddressParams != nil { + if bytes.Equal( + htlcTx.TxOut[0].PkScript, + l.ChangeAddressParams.PkScript, + ) { + + return wire.OutPoint{}, 0, fmt.Errorf("htlc tx " + + "output layout invariant violated: expected " + + "HTLC output at index 0, got change output") + } + } + + return wire.OutPoint{ + Hash: htlcTx.TxHash(), + Index: htlcInputIndex, + }, htlcTx.TxOut[htlcInputIndex].Value, nil +} + // pubkeyTo33ByteSlice converts a pubkey to a 33 byte slice. func pubkeyTo33ByteSlice(pubkey *btcec.PublicKey) [33]byte { var pubkeyBytes [33]byte @@ -508,6 +559,22 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { return total } +// ExpectedChangeAmount returns the change that a fractional loop-in should send +// to its generated static change address. A full-amount loop-in has no change. +func (l *StaticAddressLoopIn) ExpectedChangeAmount() btcutil.Amount { + if l.SelectedAmount <= 0 { + return 0 + } + + totalDepositAmount := l.TotalDepositAmount() + changeAmount := totalDepositAmount - l.SelectedAmount + if changeAmount <= 0 || changeAmount >= totalDepositAmount { + return 0 + } + + return changeAmount +} + // RemainingPaymentTimeSeconds returns the remaining time in seconds until the // payment timeout is reached. The remaining time is calculated from the // initiation time of the swap. If more than the swap's configured payment diff --git a/staticaddr/loopin/loopin_test.go b/staticaddr/loopin/loopin_test.go index 8b0892e66..574c91d75 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -12,6 +12,7 @@ import ( "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/staticaddr/version" @@ -77,7 +78,8 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { Hash: chainhash.Hash{0xaa}, Index: 0, }, - Value: depositValue, + Value: depositValue, + AddressParams: addrParams, }, } @@ -96,7 +98,7 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { ClientPubkey: clientKey.PubKey(), ServerPubkey: serverKey.PubKey(), Deposits: deposits, - AddressParams: addrParams, + ChangeAddressParams: addrParams, HtlcTxFeeRate: feeRate, SelectedAmount: selectedAmount, PaymentTimeoutSeconds: 3600, @@ -183,6 +185,76 @@ func TestPaymentTimeoutDuration(t *testing.T) { } } +// TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint verifies that timeout sweeps +// spend the actual server-published HTLC tx variant once it has been recorded. +func TestCreateHtlcSweepTxUsesConfirmedHtlcOutpoint(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{0xbb}, + Index: 0, + }, + Value: 500_000, + AddressParams: addrParams, + } + + confirmedHtlcHash := chainhash.Hash{0xcc} + confirmedHtlcValue := btcutil.Amount(275_000) + loopIn := &StaticAddressLoopIn{ + SwapHash: lntypes.Hash{3, 2, 1}, + HtlcCltvExpiry: 800, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + Deposits: []*deposit.Deposit{dep}, + HtlcTxFeeRate: chainfee.SatPerKWeight(253), + HtlcTxHash: &confirmedHtlcHash, + HtlcOutputIndex: 2, + HtlcOutputValue: confirmedHtlcValue, + } + + sweepAddr, err := btcutil.NewAddressTaproot(make([]byte, 32), network) + require.NoError(t, err) + + sweepTx, err := loopIn.createHtlcSweepTx( + t.Context(), &noopSigner{}, sweepAddr, + chainfee.SatPerKWeight(253), network, + uint32(loopIn.HtlcCltvExpiry)+1, 1, + ) + require.NoError(t, err) + require.Len(t, sweepTx.TxIn, 1) + require.Equal( + t, wire.OutPoint{ + Hash: confirmedHtlcHash, + Index: 2, + }, sweepTx.TxIn[0].PreviousOutPoint, + ) + require.Less(t, sweepTx.TxOut[0].Value, int64(confirmedHtlcValue)) + require.Greater(t, sweepTx.TxOut[0].Value, int64(0)) +} + // newStaticAddress creates a StaticAddress for testing. func newStaticAddress(clientKey, serverKey *btcec.PublicKey, csvExpiry int64) (*script.StaticAddress, error) { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index b959e9030..4377aa19e 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -21,7 +21,6 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" - "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -330,7 +329,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // If the user selected an amount that is less than the total deposit // amount we'll check that the server sends us the correct change amount // back to our static address. - err = m.checkChange(ctx, sweepTx, loopIn.AddressParams) + err = m.checkChange(ctx, sweepTx) if err != nil { return err } @@ -468,7 +467,7 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // swaps with identical change outputs. The client needs to ensure that any // swap referenced by the inputs has a respective change output in the batch. func (m *Manager) checkChange(ctx context.Context, - sweepTx *wire.MsgTx, changeAddr *script.Parameters) error { + sweepTx *wire.MsgTx) error { prevOuts := make([]string, len(sweepTx.TxIn)) for i, in := range sweepTx.TxIn { @@ -493,42 +492,67 @@ func (m *Manager) checkChange(ctx context.Context, return err } - var expectedChange btcutil.Amount + var expectedChanges []*wire.TxOut for swapHash := range swapHashes { loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash) if err != nil { return err } - totalDepositAmount := loopIn.TotalDepositAmount() - changeAmt := totalDepositAmount - loopIn.SelectedAmount - if changeAmt > 0 && changeAmt < totalDepositAmount { - log.Debugf("expected change output to our "+ - "static address, total_deposit_amount=%v, "+ - "selected_amount=%v, "+ - "expected_change_amount=%v ", - totalDepositAmount, loopIn.SelectedAmount, - changeAmt) - - expectedChange += changeAmt + changeAmt := loopIn.ExpectedChangeAmount() + if changeAmt == 0 { + continue + } + + if loopIn.ChangeAddressParams == nil { + return fmt.Errorf("missing change address for swap %x", + swapHash[:]) } + + log.Debugf("expected change output to static address, "+ + "swap_hash=%x, selected_amount=%v, "+ + "expected_change_amount=%v", swapHash[:], + loopIn.SelectedAmount, changeAmt) + + expectedChanges = append(expectedChanges, &wire.TxOut{ + Value: int64(changeAmt), + PkScript: loopIn.ChangeAddressParams.PkScript, + }) } - if expectedChange == 0 { + if len(expectedChanges) == 0 { return nil } - for _, out := range sweepTx.TxOut { - if out.Value == int64(expectedChange) && - bytes.Equal(out.PkScript, changeAddr.PkScript) { + // Match expected change outputs as a multiset. This rejects batched + // transactions that collapse two equal client change outputs into one + // output unless the protocol explicitly negotiates such aggregation. + matchedOutputs := make([]bool, len(sweepTx.TxOut)) + for _, expected := range expectedChanges { + var found bool + for i, out := range sweepTx.TxOut { + if matchedOutputs[i] { + continue + } + + if out.Value == expected.Value && + bytes.Equal(out.PkScript, expected.PkScript) { - // We found the expected change output. - return nil + matchedOutputs[i] = true + found = true + break + } } + + if found { + continue + } + + return fmt.Errorf("couldn't find expected change of %v "+ + "satoshis sent to static address", expected.Value) } - return fmt.Errorf("couldn't find expected change of %v "+ - "satoshis sent to our static address", expectedChange) + return nil } // recover stars a loop-in state machine for each non-final loop-in to pick up diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index adbfe4436..7fe2befa0 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -626,9 +626,9 @@ func TestCheckChange(t *testing.T) { var hash lntypes.Hash hash[0] = h li := &StaticAddressLoopIn{ - Deposits: deposits, - SelectedAmount: selected, - AddressParams: changeAddr, + Deposits: deposits, + SelectedAmount: selected, + ChangeAddressParams: changeAddr, } return hash, li } @@ -683,7 +683,6 @@ func TestCheckChange(t *testing.T) { name string inDeps []*deposit.Deposit // deposits referenced by tx inputs outputs []*wire.TxOut // outputs in sweep tx - addr *script.Parameters expectErr bool expectedErrMsg string } @@ -699,7 +698,6 @@ func TestCheckChange(t *testing.T) { PkScript: serverAddr.PkScript, }, }, - addr: changeAddr, }, { name: "single swap change present", @@ -714,43 +712,59 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { name: "multiple swaps different change amounts", - inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900 + inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { - Value: 900, + Value: 500, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, }, { - name: "two swaps with identical change values sum correctly", - inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800 + name: "two swaps with identical change values both present", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + { + Value: 400, + PkScript: changeAddr.PkScript, + }, + }, + }, + { + name: "collapsed identical change output rejected", + inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400) + outputs: []*wire.TxOut{ { Value: 800, PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, + expectErr: true, + expectedErrMsg: "couldn't find expected change", }, { name: "missing change output results in error", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{}, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -767,7 +781,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -784,7 +797,6 @@ func TestCheckChange(t *testing.T) { PkScript: changeAddr.PkScript, }, }, - addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, @@ -805,7 +817,6 @@ func TestCheckChange(t *testing.T) { PkScript: otherAddr.PkScript, }, }, - addr: changeAddr, }, } @@ -828,7 +839,7 @@ func TestCheckChange(t *testing.T) { mgr.cfg.DepositManager = mdm tx := makeSweepTx(inputs, tc.outputs) - err := mgr.checkChange(ctx, tx, tc.addr) + err := mgr.checkChange(ctx, tx) if tc.expectErr { require.Error(t, err) if tc.expectedErrMsg != "" { diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 4accd9080..56d68cf17 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb/sqlc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/version" "github.com/lightningnetwork/lnd/clock" @@ -287,6 +288,17 @@ func (s *SqlStore) CreateLoopIn(ctx context.Context, PaymentTimeoutSeconds: int32(loopIn.PaymentTimeoutSeconds), Fast: loopIn.Fast, } + if loopIn.ChangeAddressParams != nil { + if loopIn.ChangeAddressParams.ID == 0 { + return errors.New("static address change parameters " + + "missing database ID") + } + + staticAddressLoopInParams.ChangeStaticAddressID = sql.NullInt32{ + Int32: loopIn.ChangeAddressParams.ID, + Valid: true, + } + } updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ SwapHash: loopIn.SwapHash[:], @@ -342,6 +354,11 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, htlcTimeoutSweepTxID = loopIn.HtlcTimeoutSweepTxHash.String() } + var htlcTxID string + if loopIn.HtlcTxHash != nil { + htlcTxID = loopIn.HtlcTxHash.String() + } + updateParams := sqlc.UpdateStaticAddressLoopInParams{ SwapHash: loopIn.SwapHash[:], HtlcTxFeeRateSatKw: int64(loopIn.HtlcTxFeeRate), @@ -349,6 +366,18 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context, String: htlcTimeoutSweepTxID, Valid: htlcTimeoutSweepTxID != "", }, + ConfirmedHtlcTxID: sql.NullString{ + String: htlcTxID, + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputIndex: sql.NullInt32{ + Int32: int32(loopIn.HtlcOutputIndex), + Valid: htlcTxID != "", + }, + ConfirmedHtlcOutputValue: sql.NullInt64{ + Int64: int64(loopIn.HtlcOutputValue), + Valid: htlcTxID != "", + }, } updateArgs := sqlc.InsertStaticAddressMetaUpdateParams{ @@ -552,6 +581,16 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } + var htlcTxHash *chainhash.Hash + if swap.ConfirmedHtlcTxID.Valid { + htlcTxHash, err = chainhash.NewHashFromStr( + swap.ConfirmedHtlcTxID.String, + ) + if err != nil { + return nil, err + } + } + var depositOutpoints []string if swap.DepositOutpoints != "" { depositOutpoints = strings.Split( @@ -615,6 +654,11 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } depositList = orderDepositsBySnapshot(depositList, depositOutpoints) + changeAddressParams, err := toChangeAddressParameters(swap) + if err != nil { + return nil, err + } + loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, SwapPreimage: swapPreImage, @@ -647,7 +691,13 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, ), HtlcTimeoutSweepAddress: timeoutAddress, HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash, - Deposits: depositList, + HtlcTxHash: htlcTxHash, + HtlcOutputIndex: uint32(swap.ConfirmedHtlcOutputIndex.Int32), + HtlcOutputValue: btcutil.Amount( + swap.ConfirmedHtlcOutputValue.Int64, + ), + Deposits: depositList, + ChangeAddressParams: changeAddressParams, } if swap.ConfirmationRiskDecisionTime.Valid { loopIn.ConfirmationRiskDecisionTime = @@ -696,3 +746,41 @@ func orderDepositsBySnapshot(deposits []*deposit.Deposit, return orderedDeposits } + +// toChangeAddressParameters converts the optional joined static address row +// into the change address parameters used to verify batched sweepless sweeps. +func toChangeAddressParameters(row sqlc.GetStaticAddressLoopInSwapRow) ( + *address.Parameters, error) { + + if !row.ChangeStaticAddressID.Valid { + return nil, nil + } + + clientKey, err := btcec.ParsePubKey(row.ChangeClientPubkey) + if err != nil { + return nil, err + } + + serverKey, err := btcec.ParsePubKey(row.ChangeServerPubkey) + if err != nil { + return nil, err + } + + return &address.Parameters{ + ID: row.ChangeStaticAddressID.Int32, + ClientPubkey: clientKey, + ServerPubkey: serverKey, + Expiry: uint32(row.ChangeExpiry.Int32), + PkScript: row.ChangePkscript, + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily( + row.ChangeClientKeyFamily.Int32, + ), + Index: uint32(row.ChangeClientKeyIndex.Int32), + }, + ProtocolVersion: version.AddressProtocolVersion( + row.ChangeProtocolVersion.Int32, + ), + InitiationHeight: row.ChangeInitiationHeight.Int32, + }, nil +} diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index ffea0f065..fa66cb510 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -539,6 +539,70 @@ func TestGetLoopInByHashOrdersDepositsBySnapshot(t *testing.T) { require.Equal(t, d1.ID, storedSwap.Deposits[1].ID) } +func TestUpdateLoopInPersistsConfirmedHtlcOutpoint(t *testing.T) { + ctxb := context.Background() + testDb := loopdb.NewTestDB(t) + testClock := clock.NewTestClock(time.Now()) + defer testDb.Close() + + depositStore := deposit.NewSqlStore(testDb.BaseDB) + swapStore := NewSqlStore( + loopdb.NewTypedStore[Querier](testDb), testClock, + &chaincfg.RegressionNetParams, + ) + + depositID, err := deposit.GetRandomDepositID() + require.NoError(t, err) + + d := &deposit.Deposit{ + ID: depositID, + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + }, + Value: btcutil.Amount(100_000), + TimeOutSweepPkScript: []byte{ + 0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41, + }, + } + require.NoError(t, depositStore.CreateDeposit(ctxb, d)) + + d.SetState(deposit.LoopingIn) + require.NoError(t, depositStore.UpdateDeposit(ctxb, d)) + + _, clientPubKey := test.CreateKey(1) + _, serverPubKey := test.CreateKey(2) + addr, err := btcutil.DecodeAddress(P2wkhAddr, nil) + require.NoError(t, err) + + swapHash := lntypes.Hash{0x4, 0x2, 0x3, 0x5} + swap := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x4, 0x2, 0x3, 0x5}, + DepositOutpoints: []string{d.OutPoint.String()}, + Deposits: []*deposit.Deposit{d}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swap.SetState(MonitorInvoiceAndHtlcTx) + require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap)) + + confirmedHtlcTxHash := chainhash.Hash{0x55} + swap.HtlcTxHash = &confirmedHtlcTxHash + swap.HtlcOutputIndex = 2 + swap.HtlcOutputValue = 88_000 + require.NoError(t, swapStore.UpdateLoopIn(ctxb, &swap)) + + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.NotNil(t, storedSwap.HtlcTxHash) + require.Equal(t, confirmedHtlcTxHash, *storedSwap.HtlcTxHash) + require.EqualValues(t, 2, storedSwap.HtlcOutputIndex) + require.EqualValues(t, 88_000, storedSwap.HtlcOutputValue) + require.Equal(t, MonitorInvoiceAndHtlcTx, storedSwap.GetState()) +} + // TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins // keep the original outpoint snapshot stored when the swap was created. func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) { diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 1401d58f7..7fda54f36 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/txscript" "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/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -86,6 +87,33 @@ func DepositClientPubkeys(deposits []*deposit.Deposit) ( return clientPubkeys, nil } +// ChangeOutput converts a locally generated static address into the RPC change +// descriptor sent to the server. The descriptor binds the expected script, +// amount and client key so the server can derive and verify the same address. +func ChangeOutput(params *address.Parameters, + amount btcutil.Amount) (*swapserverrpc.StaticAddressChangeOutput, error) { + + if amount <= 0 { + return nil, nil + } + if params == nil { + return nil, fmt.Errorf("missing static address change parameters") + } + if params.ClientPubkey == nil { + return nil, fmt.Errorf("missing static address change client " + + "pubkey") + } + if len(params.PkScript) == 0 { + return nil, fmt.Errorf("missing static address change pkscript") + } + + return &swapserverrpc.StaticAddressChangeOutput{ + ClientPubkey: params.ClientPubkey.SerializeCompressed(), + PkScript: params.PkScript, + Amount: int64(amount), + }, 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 9449edaec..8343a07b3 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -212,6 +212,45 @@ func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) { }) } +func TestChangeOutput(t *testing.T) { + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + params := &address.Parameters{ + ClientPubkey: clientKey.PubKey(), + PkScript: []byte{0x51, 0x20, 0x01}, + } + amount := btcutil.Amount(12345) + + changeOutput, err := ChangeOutput(params, amount) + require.NoError(t, err) + require.Equal( + t, clientKey.PubKey().SerializeCompressed(), + changeOutput.ClientPubkey, + ) + require.Equal(t, params.PkScript, changeOutput.PkScript) + require.EqualValues(t, amount, changeOutput.Amount) + + changeOutput, err = ChangeOutput(params, 0) + require.NoError(t, err) + require.Nil(t, changeOutput) +} + +func TestChangeOutputRejectsInvalidParams(t *testing.T) { + _, err := ChangeOutput(nil, 100) + require.ErrorContains(t, err, "missing static address change parameters") + + _, err = ChangeOutput(&address.Parameters{}, 100) + require.ErrorContains(t, err, "missing static address change client pubkey") + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + _, err = ChangeOutput(&address.Parameters{ + ClientPubkey: clientKey.PubKey(), + }, 100) + require.ErrorContains(t, err, "missing static address change pkscript") +} + func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) { // Helper to create a hash from string. must := func(s string) chainhash.Hash { From 07f7f0de0458eb876a40b165491fdd16f306ae99 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 10 Jul 2026 14:23:57 +0200 Subject: [PATCH 33/35] staticaddr/withdraw: use generated change addresses Create a fresh static address for partial-withdrawal change and include its descriptor in the signing request. Build and validate the withdrawal against that address while preserving full-withdrawal behavior. --- staticaddr/withdraw/interface.go | 5 + staticaddr/withdraw/manager.go | 127 ++++++++++++------- staticaddr/withdraw/manager_test.go | 185 ++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 47 deletions(-) diff --git a/staticaddr/withdraw/interface.go b/staticaddr/withdraw/interface.go index 0f32697a9..b2698fc44 100644 --- a/staticaddr/withdraw/interface.go +++ b/staticaddr/withdraw/interface.go @@ -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 { diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index e113f7949..a3302a8ce 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -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" @@ -281,7 +281,6 @@ func (m *Manager) recoverWithdrawals(ctx context.Context) error { err = m.handleWithdrawal( ctx, deposits, tx.TxHash(), - tx.TxOut[0].PkScript, ) if err != nil { return err @@ -448,12 +447,6 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, return "", "", nil } - withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress) - if err != nil { - return "", "", fmt.Errorf("could not get withdrawal "+ - "pkscript: %w", err) - } - // If this is the first time this cluster of deposits is withdrawn, we // start a goroutine that listens for the spent of the first input of // the withdrawal transaction. @@ -469,7 +462,7 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } err = m.handleWithdrawal( - ctx, deposits, finalizedTx.TxHash(), withdrawalPkScript, + ctx, deposits, finalizedTx.TxHash(), ) if err != nil { return "", "", err @@ -567,10 +560,39 @@ 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 + changeOutput *staticaddressrpc.StaticAddressChangeOutput + ) + 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) + } + + changeOutput, err = staticutil.ChangeOutput( + changeParams, changeAmount, + ) + if err != nil { + return nil, nil, fmt.Errorf("unable to prepare 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 @@ -588,6 +610,7 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, WithdrawalPsbt: unsignedPsbt, DepositToNonces: clientNonces, DepositToClientPubkeys: depositClientPubkeys, + ChangeOutput: changeOutput, }, ) if err != nil { @@ -666,22 +689,28 @@ 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 +} + // 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) + deposits []*deposit.Deposit, originalTxHash chainhash.Hash) error { - return fmt.Errorf("withdrawal failed") + d := deposits[0] + if d.AddressParams == nil { + return fmt.Errorf("missing static address parameters for %v", + d.OutPoint) } + depositPkScript := d.AddressParams.PkScript - d := deposits[0] spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn( - ctx, &d.OutPoint, addrParams.PkScript, + ctx, &d.OutPoint, depositPkScript, int32(d.GetConfirmationHeight()), ) if err != nil { @@ -692,13 +721,19 @@ 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, nil, MinConfs, int32(m.initiationHeight.Load()), ) if err != nil { @@ -712,6 +747,18 @@ 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 + } + err = m.cfg.DepositManager.TransitionDeposits( ctx, deposits, deposit.OnWithdrawn, deposit.Withdrawn, @@ -725,13 +772,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 +936,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 +989,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, }) } diff --git a/staticaddr/withdraw/manager_test.go b/staticaddr/withdraw/manager_test.go index 6c883a7cc..bad95c05d 100644 --- a/staticaddr/withdraw/manager_test.go +++ b/staticaddr/withdraw/manager_test.go @@ -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,161 @@ 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)) +} + +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, + 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) + + err = manager.handleWithdrawal( + ctx, []*deposit.Deposit{dep}, originalTxHash, + ) + 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.Nil(t, 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. // From c3d763594780c530748cd2032ceb01430c232e8f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 8 May 2026 15:24:10 +0200 Subject: [PATCH 34/35] staticaddr: fund new addresses with sendcoins Let loop static deposit create a fresh receive address and optionally fund it through lnd SendCoins. Validate funding arguments before address creation and expose the nested request through the client RPC. Regenerate the RPC artifacts, CLI documentation, and replay fixtures for the new command behavior. --- cmd/loop/staticaddr.go | 297 +++++++++++++++++- cmd/loop/staticaddr_test.go | 28 ++ .../static-loop-in/01_loop-static-new.json | 6 +- .../static-loop-in/04_loop-static.json | 1 + docs/loop.1 | 34 ++ docs/loop.md | 29 +- go.mod | 2 +- loopd/swapclient_server.go | 158 +++++++++- loopd/swapclient_server_staticaddr_test.go | 157 +++++++++ looprpc/client.pb.go | 219 +++++++------ looprpc/client.proto | 13 + looprpc/client.swagger.json | 85 +++++ looprpc/perms.go | 2 +- 13 files changed, 922 insertions(+), 109 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index c973a0028..b78d3e646 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,11 +4,14 @@ import ( "context" "errors" "fmt" + "math" + "os" "sort" "strings" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd" @@ -17,6 +20,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/routing/route" "github.com/urfave/cli/v3" + "golang.org/x/term" ) func init() { @@ -29,6 +33,7 @@ var staticAddressCommands = &cli.Command{ Usage: "perform on-chain to off-chain swaps using static addresses.", Commands: []*cli.Command{ newStaticAddressCommand, + depositStaticAddressCommand, listUnspentCommand, listDepositsCommand, listWithdrawalsCommand, @@ -45,15 +50,99 @@ var newStaticAddressCommand = &cli.Command{ Aliases: []string{"n"}, Usage: "Create a new static loop in address.", Description: ` - Requests a new static loop in address from the server. Funds that are - sent to this address will be locked by a 2:2 multisig between us and the - loop server, or a timeout path that we can sweep once it opens up. The - funds can either be cooperatively spent with a signature from the server - or looped in. + Creates a new static loop in address. On a fresh installation loopd + initializes the static-address generation during startup. Funds sent to the + address will be locked by a 2:2 multisig between us and the loop server, or + a timeout path that we can sweep once it opens up. The funds can either be + cooperatively spent with a signature from the server or looped in. `, Action: newStaticAddress, } +var depositStaticAddressCommand = &cli.Command{ + Name: "deposit", + Usage: "Create and fund a new static loop in address.", + Description: ` + Creates a new static loop in address and initiates a deposit by calling + lnd's SendCoins API with the newly created address as the destination. + `, + Flags: []cli.Flag{ + &cli.Int64Flag{ + Name: "amt", + Usage: "the number of bitcoin denominated in satoshis " + + "to send to the new static address", + }, + &cli.BoolFlag{ + Name: "sweepall", + Usage: "if set, then the amount field should be " + + "unset. This indicates that the wallet will " + + "attempt to sweep all outputs within the " + + "wallet or all funds in selected utxos (when " + + "supplied) to the new static address", + }, + &cli.Int64Flag{ + Name: "conf_target", + Usage: "(optional) the number of blocks that the " + + "funding transaction should confirm in, will " + + "be used for fee estimation", + }, + &cli.Int64Flag{ + Name: "sat_per_byte", + Usage: "Deprecated, use sat_per_vbyte instead.", + Hidden: true, + }, + &cli.Uint64Flag{ + Name: "sat_per_vbyte", + Usage: "(optional) a manual fee expressed in " + + "sat/vbyte that should be used when crafting " + + "the funding transaction", + }, + &cli.Uint64Flag{ + Name: "min_confs", + Usage: "(optional) the minimum number of confirmations " + + "each one of your outputs used for the funding " + + "transaction must satisfy", + Value: defaultUtxoMinConf, + }, + &cli.BoolFlag{ + Name: "force, f", + Usage: "if set, the funding transaction will be " + + "broadcast without asking for confirmation", + }, + staticAddressCoinSelectionStrategyFlag, + &cli.StringSliceFlag{ + Name: "utxo", + Usage: "a utxo specified as outpoint(tx:idx) which " + + "will be used as input for the funding " + + "transaction. This flag can be repeatedly used " + + "to specify multiple utxos as inputs. The " + + "selected utxos can either be entirely spent " + + "by specifying the sweepall flag or a specified " + + "amount can be spent in the utxos through " + + "the amt flag", + }, + staticAddressFundingLabelFlag, + }, + Action: depositStaticAddress, +} + +var ( + staticAddressCoinSelectionStrategyFlag = &cli.StringFlag{ + Name: "coin_selection_strategy", + Usage: "(optional) the strategy to use for selecting coins. " + + "Possible values are 'largest', 'random', or " + + "'global-config'. If either 'largest' or 'random' is " + + "specified, it will override the globally configured " + + "strategy in lnd.conf", + Value: "global-config", + } + + staticAddressFundingLabelFlag = &cli.StringFlag{ + Name: "label", + Usage: "(optional) a label for the funding transaction", + } +) + func newStaticAddress(ctx context.Context, cmd *cli.Command) error { if cmd.NArg() > 0 { return showCommandHelp(ctx, cmd) @@ -82,6 +171,186 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error { return nil } +func depositStaticAddress(ctx context.Context, cmd *cli.Command) error { + if cmd.NArg() > 0 { + return showCommandHelp(ctx, cmd) + } + + client, cleanup, err := getClient(cmd) + if err != nil { + return err + } + defer cleanup() + + req, err := staticAddressDepositRequest(cmd, "") + if err != nil { + return err + } + + err = maybeDisplayNewAddressWarning(ctx, client) + if err != nil { + return err + } + + addrResp, err := client.NewStaticAddress( + ctx, &looprpc.NewStaticAddressRequest{}, + ) + if err != nil { + return err + } + + req.GetSendCoinsRequest().Addr = addrResp.Address + + if !(cmd.Bool("force") || cmd.Bool("f")) && + term.IsTerminal(int(os.Stdout.Fd())) { + + if !confirmStaticAddressDeposit(req, addrResp.Address) { + return nil + } + } + + resp, err := client.NewStaticAddress(ctx, req) + if err != nil { + return err + } + + printRespJSON(resp) + + return nil +} + +func staticAddressDepositRequest( + cmd *cli.Command, addr string) (*looprpc.NewStaticAddressRequest, error) { + + if !cmd.IsSet("amt") && !cmd.Bool("sweepall") { + return nil, errors.New("amount argument missing") + } + + amount := cmd.Int64("amt") + if cmd.IsSet("amt") && amount <= 0 { + return nil, errors.New("amount must be positive") + } + + if amount != 0 && cmd.Bool("sweepall") { + return nil, errors.New("amount cannot be set if " + + "attempting to sweep all coins out of the wallet") + } + + feeRateFlag, err := checkNotBothSet( + cmd, "sat_per_vbyte", "sat_per_byte", + ) + if err != nil { + return nil, err + } + + if _, err := checkNotBothSet( + cmd, feeRateFlag, "conf_target", + ); err != nil { + return nil, err + } + + var satPerByte int64 + if cmd.IsSet("sat_per_byte") { + satPerByte = cmd.Int64("sat_per_byte") + if satPerByte < 0 { + return nil, fmt.Errorf("sat_per_byte must be " + + "non-negative") + } + } + + confTarget := cmd.Int64("conf_target") + if confTarget < 0 { + return nil, fmt.Errorf("conf_target must be non-negative") + } + if confTarget > math.MaxInt32 { + return nil, fmt.Errorf("conf_target exceeds maximum " + + "int32 value") + } + + minConfs := cmd.Uint64("min_confs") + if minConfs > math.MaxInt32 { + return nil, fmt.Errorf("min_confs exceeds maximum " + + "int32 value") + } + + var outpoints []*lnrpc.OutPoint + utxos := cmd.StringSlice("utxo") + if len(utxos) > 0 { + outpoints, err = lnd.UtxosToOutpoints(utxos) + if err != nil { + return nil, fmt.Errorf("unable to decode utxos: %w", err) + } + } + + coinSelectionStrategy, err := parseStaticAddressCoinSelectionStrategy(cmd) + if err != nil { + return nil, err + } + + return &looprpc.NewStaticAddressRequest{ + SendCoinsRequest: &lnrpc.SendCoinsRequest{ + Addr: addr, + Amount: amount, + TargetConf: int32(confTarget), + SatPerVbyte: cmd.Uint64("sat_per_vbyte"), + SatPerByte: satPerByte, + SendAll: cmd.Bool("sweepall"), + Label: cmd.String( + staticAddressFundingLabelFlag.Name, + ), + MinConfs: int32(minConfs), + SpendUnconfirmed: minConfs == 0, + CoinSelectionStrategy: coinSelectionStrategy, + Outpoints: outpoints, + }, + }, nil +} + +func parseStaticAddressCoinSelectionStrategy(cmd *cli.Command) ( + lnrpc.CoinSelectionStrategy, error) { + + if !cmd.IsSet(staticAddressCoinSelectionStrategyFlag.Name) { + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + } + + switch strategy := cmd.String( + staticAddressCoinSelectionStrategyFlag.Name); strategy { + case "global-config": + return lnrpc.CoinSelectionStrategy_STRATEGY_USE_GLOBAL_CONFIG, + nil + + case "largest": + return lnrpc.CoinSelectionStrategy_STRATEGY_LARGEST, nil + + case "random": + return lnrpc.CoinSelectionStrategy_STRATEGY_RANDOM, nil + + default: + return 0, fmt.Errorf("unknown coin selection strategy %v", + strategy) + } +} + +func confirmStaticAddressDeposit(req *looprpc.NewStaticAddressRequest, + addr string) bool { + + sendCoinsReq := req.GetSendCoinsRequest() + if sendCoinsReq.GetSendAll() { + fmt.Println("Amount: sweep all eligible wallet funds") + } else { + fmt.Printf("Amount: %d\n", sendCoinsReq.GetAmount()) + } + + fmt.Printf("Destination address: %s\n", addr) + fmt.Printf("Confirm funding transaction (yes/no): ") + + var answer string + fmt.Scanln(&answer) + + return answer == "yes" || answer == "y" +} + var listUnspentCommand = &cli.Command{ Name: "listunspent", Aliases: []string{"l"}, @@ -846,6 +1115,24 @@ func lowConfDepositWarning(allDeposits []*looprpc.Deposit, ) } +func maybeDisplayNewAddressWarning(ctx context.Context, + client looprpc.SwapClientClient) error { + + _, err := client.GetStaticAddressSummary( + ctx, &looprpc.StaticAddressSummaryRequest{}, + ) + switch { + case err == nil: + return nil + + case strings.Contains(err.Error(), address.ErrNoStaticAddress.Error()): + return displayNewAddressWarning() + + default: + return nil + } +} + func displayNewAddressWarning() error { fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " + ".loop under your home directory will take your ability to " + diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2f7bcfb84..71aafa4c0 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "strings" "testing" @@ -12,8 +13,35 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) +func TestStaticAddressDepositRequestAllowsNoUtxos(t *testing.T) { + t.Parallel() + + var req *looprpc.NewStaticAddressRequest + cmd := &cli.Command{ + Name: "deposit", + Flags: depositStaticAddressCommand.Flags, + Action: func(_ context.Context, cmd *cli.Command) error { + var err error + req, err = staticAddressDepositRequest( + cmd, "bcrt1ptestaddress", + ) + + return err + }, + } + + err := cmd.Run(context.Background(), []string{ + "deposit", "--amt", "1000000", + }) + require.NoError(t, err) + require.Equal(t, "bcrt1ptestaddress", req.GetSendCoinsRequest().Addr) + require.EqualValues(t, 1_000_000, req.GetSendCoinsRequest().Amount) + require.Empty(t, req.GetSendCoinsRequest().Outpoints) +} + // TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the // conservative warning threshold are included in the warning text. func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { diff --git a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json index 69a7ab554..e196eaa41 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json +++ b/cmd/loop/testdata/sessions/static-loop-in/01_loop-static-new.json @@ -40,7 +40,8 @@ "event": "request", "message_type": "looprpc.NewStaticAddressRequest", "payload": { - "client_key": "" + "client_key": "", + "send_coins_request": null } } }, @@ -64,7 +65,8 @@ "lines": [ "{\n", " \"address\": \"bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw\",\n", - " \"expiry\": 14400\n", + " \"expiry\": 14400,\n", + " \"send_coins_response\": null\n", "}\n" ] } diff --git a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json index 2b5335b75..322f930c1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json +++ b/cmd/loop/testdata/sessions/static-loop-in/04_loop-static.json @@ -25,6 +25,7 @@ "\n", "COMMANDS:\n", " new, n Create a new static loop in address.\n", + " deposit Create and fund a new static loop in address.\n", " listunspent, l List unspent static address outputs.\n", " listdeposits Displays static address deposits. A filter can be applied to only show deposits in a specific state.\n", " listwithdrawals Display a summary of past withdrawals.\n", diff --git a/docs/loop.1 b/docs/loop.1 index ba1ca3e16..b855131e8 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -454,6 +454,40 @@ Create a new static loop in address. .PP \fB--help, -h\fP: show help +.SS deposit +.PP +Create and fund a new static loop in address. + +.PP +\fB--amt\fP="": the number of bitcoin denominated in satoshis to send to the new static address (default: 0) + +.PP +\fB--coin_selection_strategy\fP="": (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf (default: global-config) + +.PP +\fB--conf_target\fP="": (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation (default: 0) + +.PP +\fB--force\fP: if set, the funding transaction will be broadcast without asking for confirmation + +.PP +\fB--help, -h\fP: show help + +.PP +\fB--label\fP="": (optional) a label for the funding transaction + +.PP +\fB--min_confs\fP="": (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy (default: 1) + +.PP +\fB--sat_per_vbyte\fP="": (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction (default: 0) + +.PP +\fB--sweepall\fP: if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address + +.PP +\fB--utxo\fP="": a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag (default: []) + .SS listunspent, l .PP List unspent static address outputs. diff --git a/docs/loop.md b/docs/loop.md index 2f48e9471..b2c0f9e12 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -532,7 +532,7 @@ The following flags are supported: Create a new static loop in address. -Requests a new static loop in address from the server. Funds that are sent to this address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. +Creates a new static loop in address. On a fresh installation loopd initializes the static-address generation during startup. Funds sent to the address will be locked by a 2:2 multisig between us and the loop server, or a timeout path that we can sweep once it opens up. The funds can either be cooperatively spent with a signature from the server or looped in. Usage: @@ -546,6 +546,33 @@ The following flags are supported: |-----------------|-------------|------|:-------------:| | `--help` (`-h`) | show help | bool | `false` | +### `static deposit` subcommand + +Create and fund a new static loop in address. + +Creates a new static loop in address and initiates a deposit by calling lnd's SendCoins API with the newly created address as the destination. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] static deposit [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|:---------------:| +| `--amt="…"` | the number of bitcoin denominated in satoshis to send to the new static address | int | `0` | +| `--sweepall` | if set, then the amount field should be unset. This indicates that the wallet will attempt to sweep all outputs within the wallet or all funds in selected utxos (when supplied) to the new static address | bool | `false` | +| `--conf_target="…"` | (optional) the number of blocks that the funding transaction should confirm in, will be used for fee estimation | int | `0` | +| `--sat_per_vbyte="…"` | (optional) a manual fee expressed in sat/vbyte that should be used when crafting the funding transaction | uint | `0` | +| `--min_confs="…"` | (optional) the minimum number of confirmations each one of your outputs used for the funding transaction must satisfy | uint | `1` | +| `--force` | if set, the funding transaction will be broadcast without asking for confirmation | bool | `false` | +| `--coin_selection_strategy="…"` | (optional) the strategy to use for selecting coins. Possible values are 'largest', 'random', or 'global-config'. If either 'largest' or 'random' is specified, it will override the globally configured strategy in lnd.conf | string | `global-config` | +| `--utxo="…"` | a utxo specified as outpoint(tx:idx) which will be used as input for the funding transaction. This flag can be repeatedly used to specify multiple utxos as inputs. The selected utxos can either be entirely spent by specifying the sweepall flag or a specified amount can be spent in the utxos through the amt flag | string | `[]` | +| `--label="…"` | (optional) a label for the funding transaction | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `static listunspent` subcommand (aliases: `l`) List unspent static address outputs. diff --git a/go.mod b/go.mod index c853da929..c8189d92b 100644 --- a/go.mod +++ b/go.mod @@ -196,7 +196,7 @@ require ( golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect + golang.org/x/term v0.41.0 golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.40.0 // indirect diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d8d98a6b8..1a66f5526 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -38,6 +38,8 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightninglabs/taproot-assets/rfqmath" + lndlabels "github.com/lightningnetwork/lnd/labels" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/queue" @@ -45,6 +47,7 @@ import ( "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) const ( @@ -1710,20 +1713,169 @@ func rpcInstantOut(instantOut *instantout.InstantOut) *looprpc.InstantOut { // NewStaticAddress is the rpc endpoint for loop clients to request a new static // address. func (s *swapClientServer) NewStaticAddress(ctx context.Context, - _ *looprpc.NewStaticAddressRequest) ( + req *looprpc.NewStaticAddressRequest) ( *looprpc.NewStaticAddressResponse, error) { + sendCoinsReq := req.GetSendCoinsRequest() + if err := validateStaticAddressSendCoinsRequest(sendCoinsReq); err != nil { + return nil, err + } + + if sendCoinsReq.GetAddr() != "" { + return s.fundExistingStaticAddress(ctx, sendCoinsReq) + } + staticAddress, expiry, err := s.staticAddressManager.NewAddress(ctx) if err != nil { return nil, err } + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress.String(), sendCoinsReq, + ) + if err != nil { + return nil, fmt.Errorf("static address %s created, but "+ + "funding transaction failed: %w", staticAddress, err) + } + + return &looprpc.NewStaticAddressResponse{ + Address: staticAddress.String(), + Expiry: uint32(expiry), + SendCoinsResponse: sendCoinsResp, + }, nil +} + +func (s *swapClientServer) fundExistingStaticAddress(ctx context.Context, + req *lnrpc.SendCoinsRequest) (*looprpc.NewStaticAddressResponse, error) { + + staticAddress, expiry, err := s.staticAddressForDeposit(ctx, req.Addr) + if err != nil { + return nil, err + } + + sendCoinsResp, err := s.sendCoinsToStaticAddress( + ctx, staticAddress, req, + ) + if err != nil { + return nil, fmt.Errorf("static address %s funding transaction "+ + "failed: %w", staticAddress, err) + } + return &looprpc.NewStaticAddressResponse{ - Address: staticAddress.String(), - Expiry: uint32(expiry), + Address: staticAddress, + Expiry: expiry, + SendCoinsResponse: sendCoinsResp, }, nil } +func (s *swapClientServer) staticAddressForDeposit(ctx context.Context, + addr string) (string, uint32, error) { + + addresses, err := s.staticAddressManager.GetAllAddresses(ctx) + if err != nil { + return "", 0, err + } + + for _, params := range addresses { + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, + int64(params.Expiry), + ) + if err != nil { + return "", 0, err + } + + if staticAddress.String() == addr { + return addr, params.Expiry, nil + } + } + + return "", 0, status.Errorf(codes.InvalidArgument, + "send_coins_request.addr is not a known static address") +} + +func validateStaticAddressSendCoinsRequest(req *lnrpc.SendCoinsRequest) error { + if req == nil { + return nil + } + + switch { + case req.Amount < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount must be non-negative") + + case req.Amount == 0 && !req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "must set amount or send_all") + + case req.Amount != 0 && req.SendAll: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "amount cannot be set when send_all is true") + + case req.TargetConf < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "target_conf must be non-negative") + + case req.SatPerByte < 0: //nolint:staticcheck + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "sat_per_byte must be non-negative") + + case req.TargetConf != 0 && + (req.SatPerVbyte != 0 || req.SatPerByte != 0): //nolint:staticcheck + + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either target_conf or a fee rate, but not both") + + case req.SatPerVbyte != 0 && req.SatPerByte != 0: //nolint:staticcheck + return status.Error(codes.InvalidArgument, "send_coins_request "+ + "can set either sat_per_vbyte or sat_per_byte, but not "+ + "both") + + case req.MinConfs < 0: + return status.Error(codes.InvalidArgument, "send_coins_request."+ + "min_confs must be non-negative") + } + + if _, err := lnrpc.ExtractMinConfs( + req.MinConfs, req.SpendUnconfirmed, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "min_confs/spend_unconfirmed invalid: %v", err) + } + + if _, err := lndlabels.ValidateAPI(req.Label); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "label invalid: %v", err) + } + + if _, err := lnrpc.UnmarshallCoinSelectionStrategy( + req.CoinSelectionStrategy, nil, + ); err != nil { + return status.Errorf(codes.InvalidArgument, "send_coins_request "+ + "coin_selection_strategy invalid: %v", err) + } + + return nil +} + +func (s *swapClientServer) sendCoinsToStaticAddress(ctx context.Context, + addr string, req *lnrpc.SendCoinsRequest) (*lnrpc.SendCoinsResponse, + error) { + + if req == nil { + return nil, nil + } + + sendCoinsReq := proto.Clone(req).(*lnrpc.SendCoinsRequest) + sendCoinsReq.Addr = addr + + rawCtx, timeout, rawClient := s.lnd.Client.RawClientWithMacAuth(ctx) + rawCtx, cancel := context.WithTimeout(rawCtx, timeout) + defer cancel() + + return rawClient.SendCoins(rawCtx, sendCoinsReq) +} + // ListUnspentDeposits returns a list of utxos behind the static address. func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, req *looprpc.ListUnspentDepositsRequest) ( diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index 9478109bb..bb588d5cd 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -2,6 +2,7 @@ package loopd import ( "context" + "strings" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -14,6 +15,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" mock_lnd "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" ) @@ -168,6 +170,161 @@ func newTestStaticAddressContext(t *testing.T) (*address.Manager, return addrMgr, mock } +func TestValidateStaticAddressSendCoinsRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + req *lnrpc.SendCoinsRequest + err string + }{ + { + name: "nil", + }, + { + name: "amount", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + }, + }, + { + name: "send all", + req: &lnrpc.SendCoinsRequest{ + SendAll: true, + }, + }, + { + name: "existing addr", + req: &lnrpc.SendCoinsRequest{ + Addr: "bcrt1ptestaddress", + Amount: 10_000, + }, + }, + { + name: "missing amount", + req: &lnrpc.SendCoinsRequest{}, + err: "must set amount or send_all", + }, + { + name: "negative amount", + req: &lnrpc.SendCoinsRequest{ + Amount: -1, + }, + err: "amount must be non-negative", + }, + { + name: "amount and send all", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SendAll: true, + }, + err: "amount cannot be set when send_all is true", + }, + { + name: "target and fee rate", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + TargetConf: 6, + SatPerVbyte: 1, + SatPerByte: 0, + SendAll: false, + MinConfs: 1, + Outpoints: nil, + SpendUnconfirmed: false, + }, + err: "can set either target_conf or a fee rate", + }, + { + name: "both fee rates", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + SatPerVbyte: 1, + SatPerByte: 1, + }, + err: "can set either sat_per_vbyte or sat_per_byte", + }, + { + name: "negative min confs", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: -1, + }, + err: "min_confs must be non-negative", + }, + { + name: "min confs with spend unconfirmed", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + MinConfs: 1, + SpendUnconfirmed: true, + }, + err: "spend_unconfirmed invalid", + }, + { + name: "invalid label", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + Label: strings.Repeat("x", 501), + }, + err: "label invalid", + }, + { + name: "invalid coin selection strategy", + req: &lnrpc.SendCoinsRequest{ + Amount: 10_000, + CoinSelectionStrategy: lnrpc.CoinSelectionStrategy(99), + }, + err: "coin_selection_strategy invalid", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := validateStaticAddressSendCoinsRequest(test.req) + if test.err == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestStaticAddressForDeposit(t *testing.T) { + t.Parallel() + + ctx := context.Background() + addrMgr, _ := newTestStaticAddressContext(t) + server := &swapClientServer{ + staticAddressManager: addrMgr, + } + + addresses, err := addrMgr.GetAllAddresses(ctx) + require.NoError(t, err) + require.Len(t, addresses, 1) + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + + addr, expiry, err := server.staticAddressForDeposit( + ctx, expectedAddr.String(), + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), addr) + require.Equal(t, addresses[0].Expiry, expiry) + + _, _, err = server.staticAddressForDeposit( + ctx, "bcrt1punknownstaticaddress", + ) + require.ErrorContains(t, err, "not a known static address") +} + // TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit // listings include visible deposit records. func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 3305bfcde..e9c104421 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -4838,9 +4838,14 @@ func (x *InstantOut) GetSweepTxId() string { type NewStaticAddressRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The client's public key for the 2-of-2 MuSig2 taproot static address. - ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ClientKey []byte `protobuf:"bytes,1,opt,name=client_key,json=clientKey,proto3" json:"client_key,omitempty"` + // If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + // request's addr field is empty, loopd creates and funds a new static + // address. If addr is set, it must be an existing static address known to + // loopd. + SendCoinsRequest *lnrpc.SendCoinsRequest `protobuf:"bytes,2,opt,name=send_coins_request,json=sendCoinsRequest,proto3" json:"send_coins_request,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressRequest) Reset() { @@ -4880,14 +4885,23 @@ func (x *NewStaticAddressRequest) GetClientKey() []byte { return nil } +func (x *NewStaticAddressRequest) GetSendCoinsRequest() *lnrpc.SendCoinsRequest { + if x != nil { + return x.SendCoinsRequest + } + return nil +} + type NewStaticAddressResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The taproot static address. Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` // The CSV expiry of the static address. - Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expiry uint32 `protobuf:"varint,2,opt,name=expiry,proto3" json:"expiry,omitempty"` + // The response from lnd's SendCoins API, if a deposit was initiated. + SendCoinsResponse *lnrpc.SendCoinsResponse `protobuf:"bytes,3,opt,name=send_coins_response,json=sendCoinsResponse,proto3" json:"send_coins_response,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NewStaticAddressResponse) Reset() { @@ -4934,6 +4948,13 @@ func (x *NewStaticAddressResponse) GetExpiry() uint32 { return 0 } +func (x *NewStaticAddressResponse) GetSendCoinsResponse() *lnrpc.SendCoinsResponse { + if x != nil { + return x.SendCoinsResponse + } + return nil +} + type ListUnspentDepositsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // The number of minimum confirmations a utxo must have to be listed. @@ -6952,13 +6973,15 @@ const file_client_proto_rawDesc = "" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x16\n" + "\x06amount\x18\x03 \x01(\x04R\x06amount\x12'\n" + "\x0freservation_ids\x18\x04 \x03(\fR\x0ereservationIds\x12\x1e\n" + - "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"8\n" + + "\vsweep_tx_id\x18\x05 \x01(\tR\tsweepTxId\"\x7f\n" + "\x17NewStaticAddressRequest\x12\x1d\n" + "\n" + - "client_key\x18\x01 \x01(\fR\tclientKey\"L\n" + + "client_key\x18\x01 \x01(\fR\tclientKey\x12E\n" + + "\x12send_coins_request\x18\x02 \x01(\v2\x17.lnrpc.SendCoinsRequestR\x10sendCoinsRequest\"\x96\x01\n" + "\x18NewStaticAddressResponse\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x16\n" + - "\x06expiry\x18\x02 \x01(\rR\x06expiry\"V\n" + + "\x06expiry\x18\x02 \x01(\rR\x06expiry\x12H\n" + + "\x13send_coins_response\x18\x03 \x01(\v2\x18.lnrpc.SendCoinsResponseR\x11sendCoinsResponse\"V\n" + "\x1aListUnspentDepositsRequest\x12\x1b\n" + "\tmin_confs\x18\x01 \x01(\x05R\bminConfs\x12\x1b\n" + "\tmax_confs\x18\x02 \x01(\x05R\bmaxConfs\"B\n" + @@ -7311,7 +7334,9 @@ var file_client_proto_goTypes = []any{ nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint + (*lnrpc.SendCoinsRequest)(nil), // 92: lnrpc.SendCoinsRequest + (*lnrpc.SendCoinsResponse)(nil), // 93: lnrpc.SendCoinsResponse + (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest @@ -7350,92 +7375,94 @@ var file_client_proto_depIdxs = []int32{ 51, // 33: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified 57, // 34: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation 64, // 35: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 92, // 37: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 38: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 41: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 42: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 43: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 44: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 91, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 50: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 51: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 52: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 53: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 54: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 55: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 56: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 57: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 58: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 59: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 60: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 61: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 62: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 63: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 64: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 65: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 66: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 67: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 68: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 69: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 70: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 71: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 72: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 73: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 74: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 75: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 76: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 77: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 78: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 79: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 80: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 81: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 82: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 83: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 84: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 85: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 86: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 87: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 88: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 89: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 90: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 91: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 92: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 93: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 94: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 95: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 96: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 97: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 98: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 99: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 100: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 101: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 102: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 103: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 104: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 105: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 106: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 107: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 108: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 109: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 110: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 111: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 112: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 113: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 114: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 115: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 116: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 84, // [84:117] is the sub-list for method output_type - 51, // [51:84] is the sub-list for method input_type - 51, // [51:51] is the sub-list for extension type_name - 51, // [51:51] is the sub-list for extension extendee - 0, // [0:51] is the sub-list for field type_name + 92, // 36: looprpc.NewStaticAddressRequest.send_coins_request:type_name -> lnrpc.SendCoinsRequest + 93, // 37: looprpc.NewStaticAddressResponse.send_coins_response:type_name -> lnrpc.SendCoinsResponse + 69, // 38: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 94, // 39: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 40: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 41: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 42: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 43: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 44: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 45: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 46: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 47: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 48: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 49: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 50: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 51: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 52: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 53: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 54: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 55: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 56: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 57: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 58: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 59: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 60: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 61: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 62: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 63: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 64: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 65: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 66: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 67: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 68: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 69: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 70: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 71: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 72: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 73: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 74: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 75: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 76: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 77: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 78: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 79: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 80: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 81: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 82: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 83: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 84: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 85: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 86: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 87: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 88: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 89: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 90: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 91: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 92: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 93: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 94: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 95: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 96: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 97: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 98: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 99: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 100: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 101: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 102: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 103: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 104: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 105: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 106: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 107: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 108: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 109: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 110: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 111: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 112: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 113: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 114: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 115: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 116: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 117: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 118: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 86, // [86:119] is the sub-list for method output_type + 53, // [53:86] is the sub-list for method input_type + 53, // [53:53] is the sub-list for extension type_name + 53, // [53:53] is the sub-list for extension extendee + 0, // [0:53] is the sub-list for field type_name } func init() { file_client_proto_init() } diff --git a/looprpc/client.proto b/looprpc/client.proto index 3ae690dc3..3f41ac11c 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1767,6 +1767,14 @@ message NewStaticAddressRequest { The client's public key for the 2-of-2 MuSig2 taproot static address. */ bytes client_key = 1; + + /* + If set, loopd initiates a deposit by calling lnd's SendCoins API. If the + request's addr field is empty, loopd creates and funds a new static + address. If addr is set, it must be an existing static address known to + loopd. + */ + lnrpc.SendCoinsRequest send_coins_request = 2; } message NewStaticAddressResponse { @@ -1779,6 +1787,11 @@ message NewStaticAddressResponse { The CSV expiry of the static address. */ uint32 expiry = 2; + + /* + The response from lnd's SendCoins API, if a deposit was initiated. + */ + lnrpc.SendCoinsResponse send_coins_response = 3; } message ListUnspentDepositsRequest { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index a50814e4e..1aa9036bf 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1204,6 +1204,16 @@ } } }, + "lnrpcCoinSelectionStrategy": { + "type": "string", + "enum": [ + "STRATEGY_USE_GLOBAL_CONFIG", + "STRATEGY_LARGEST", + "STRATEGY_RANDOM" + ], + "default": "STRATEGY_USE_GLOBAL_CONFIG", + "description": " - STRATEGY_USE_GLOBAL_CONFIG: Use the coin selection strategy defined in the global configuration\n(lnd.conf).\n - STRATEGY_LARGEST: Select the largest available coins first during coin selection.\n - STRATEGY_RANDOM: Randomly select the available coins during coin selection." + }, "lnrpcCommitmentType": { "type": "string", "enum": [ @@ -1436,6 +1446,73 @@ } } }, + "lnrpcSendCoinsRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "title": "The address to send coins to" + }, + "amount": { + "type": "string", + "format": "int64", + "title": "The amount in satoshis to send" + }, + "target_conf": { + "type": "integer", + "format": "int32", + "description": "The target number of blocks that this transaction should be confirmed\nby." + }, + "sat_per_vbyte": { + "type": "string", + "format": "uint64", + "description": "A manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "sat_per_byte": { + "type": "string", + "format": "int64", + "description": "Deprecated, use sat_per_vbyte.\nA manual fee rate set in sat/vbyte that should be used when crafting the\ntransaction." + }, + "send_all": { + "type": "boolean", + "description": "If set, the amount field should be unset. It indicates lnd will send all\nwallet coins or all selected coins to the specified address." + }, + "label": { + "type": "string", + "description": "An optional label for the transaction, limited to 500 characters." + }, + "min_confs": { + "type": "integer", + "format": "int32", + "description": "The minimum number of confirmations each one of your outputs used for\nthe transaction must satisfy." + }, + "spend_unconfirmed": { + "type": "boolean", + "description": "Whether unconfirmed outputs should be used as inputs for the transaction." + }, + "coin_selection_strategy": { + "$ref": "#/definitions/lnrpcCoinSelectionStrategy", + "description": "The strategy to use for selecting coins." + }, + "outpoints": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcOutPoint" + }, + "description": "A list of selected outpoints as inputs for the transaction." + } + } + }, + "lnrpcSendCoinsResponse": { + "type": "object", + "properties": { + "txid": { + "type": "string", + "title": "The transaction ID of the transaction" + } + } + }, "looprpcAbandonSwapResponse": { "type": "object" }, @@ -2515,6 +2592,10 @@ "type": "string", "format": "byte", "description": "The client's public key for the 2-of-2 MuSig2 taproot static address." + }, + "send_coins_request": { + "$ref": "#/definitions/lnrpcSendCoinsRequest", + "description": "If set, loopd initiates a deposit by calling lnd's SendCoins API. If the\nrequest's addr field is empty, loopd creates and funds a new static\naddress. If addr is set, it must be an existing static address known to\nloopd." } } }, @@ -2529,6 +2610,10 @@ "type": "integer", "format": "int64", "description": "The CSV expiry of the static address." + }, + "send_coins_response": { + "$ref": "#/definitions/lnrpcSendCoinsResponse", + "description": "The response from lnd's SendCoins API, if a deposit was initiated." } } }, diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..0a1b5c991 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -82,7 +82,7 @@ var RequiredPermissions = map[string][]bakery.Op{ }}, "/looprpc.SwapClient/NewStaticAddress": {{ Entity: "swap", - Action: "read", + Action: "execute", }, { Entity: "loop", Action: "in", From c1c691e6e5fcae70fc865da765af56107feb71ea Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 11 May 2026 13:43:43 +0200 Subject: [PATCH 35/35] staticaddr: expose addresses in deposit listings Include the owning static address in every deposit RPC response and CLI listing. Users can distinguish deposits created by different receive and change addresses without reconstructing scripts externally. Update generated RPC artifacts and command replay fixtures for the new field. --- ..._loop-static-listdeposits-withdrawing.json | 1 + ...03_loop-static-listdeposits-withdrawn.json | 4 + ...05_loop-static-listdeposits-looped_in.json | 1 + .../11_loop-static-listdeposits-failed.json | 6 + ...stdeposits-channel_published-nonempty.json | 2 + .../10_loop-static-listdeposits.json | 1 + .../static-loop-in/15_loop-static-in.json | 1 + ...-static-in-positional-payment-timeout.json | 1 + .../23_loop-static-in-max-swap-fee-both.json | 1 + ...op-static-in-max-swap-fee-sat-success.json | 1 + .../02_loop-static-listwithdrawals.json | 1 + loopd/swapclient_server.go | 148 +++++++++++------- loopd/swapclient_server_staticaddr_test.go | 15 ++ loopd/swapclient_server_test.go | 20 ++- looprpc/client.pb.go | 16 +- looprpc/client.proto | 5 + looprpc/client.swagger.json | 4 + 17 files changed, 159 insertions(+), 69 deletions(-) diff --git a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json index 99186eb26..5876bff6d 100644 --- a/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json +++ b/cmd/loop/testdata/sessions/static-filters/02_loop-static-listdeposits-withdrawing.json @@ -65,6 +65,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json index 79c024585..ab221508b 100644 --- a/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json +++ b/cmd/loop/testdata/sessions/static-filters/03_loop-static-listdeposits-withdrawn.json @@ -92,6 +92,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -101,6 +102,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -110,6 +112,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +122,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json index e935ed5eb..92d14aa1a 100644 --- a/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json +++ b/cmd/loop/testdata/sessions/static-filters/05_loop-static-listdeposits-looped_in.json @@ -65,6 +65,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json index 407287ffd..6dbf9009d 100644 --- a/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json +++ b/cmd/loop/testdata/sessions/static-filters/11_loop-static-listdeposits-failed.json @@ -110,6 +110,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -119,6 +120,7 @@ " \"id\": \"86b5e2cdf9694c8e7398e42afde109766d7cd2142203905ba63fbd0eb1370ef3\",\n", " \"outpoint\": \"bb358e4f73ae97c4e2d99c6d64e852bba7cf56e13105b05d1200b8ae1796665e:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -128,6 +130,7 @@ " \"id\": \"6c290f7536ea5097946afffac6a69906a26d775823ebbacedfe6f2d69c0745e4\",\n", " \"outpoint\": \"5eaa7dd7a291665393eddf5dece91feef901f22665933cce7a0732a9b81c3001:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -137,6 +140,7 @@ " \"id\": \"0182b4d895b1c467290ae7b5c6c42ff76b2a4225807a94211c973170d5a883eb\",\n", " \"outpoint\": \"7e6360d6e6a394cfd096adf0bfe1275c5a83541eb573e90e463a78dc715f8894:0\",\n", " \"state\": \"WITHDRAWN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -146,6 +150,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPED_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n", " \"value\": \"500000\"\n", " },\n", @@ -155,6 +160,7 @@ " \"id\": \"bb7f050df0b7c3e1fe61010e10ad45e30ddf7acd301fa6e05a2ddb825b5c2efb\",\n", " \"outpoint\": \"56cd081a3a6eadf25b7d3fe0b61207389352ed69a622d2ec28c5d669bf6a5313:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json index 1cb79c441..12689a556 100644 --- a/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json +++ b/cmd/loop/testdata/sessions/static-filters/14_loop-static-listdeposits-channel_published-nonempty.json @@ -77,6 +77,7 @@ " \"id\": \"7a7cbe9b90f23d47aa92eb10a9d323f7ace6e9eaab5b77379c63422c15da19c8\",\n", " \"outpoint\": \"0e70673c1da3343648c26f779555346f30d235314838b1160826d0d5c29b4fba:1\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " },\n", @@ -86,6 +87,7 @@ " \"id\": \"ff9a43b2082f906a2e2758934220c4ce32393eb2823b292517ae081e16daded9\",\n", " \"outpoint\": \"d2d6e50f157f0d31b8688a4af4f064edf3454714e92369b2c8c4d82477edbaca:0\",\n", " \"state\": \"CHANNEL_PUBLISHED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"1000000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json index ff4d0d14d..e22efed83 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json +++ b/cmd/loop/testdata/sessions/static-loop-in/10_loop-static-listdeposits.json @@ -61,6 +61,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"DEPOSITED\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json index 5acb8ddf0..49c6a9ef1 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json +++ b/cmd/loop/testdata/sessions/static-loop-in/15_loop-static-in.json @@ -228,6 +228,7 @@ " \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n", " \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"2500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json index b997322b0..666c2a7ea 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json +++ b/cmd/loop/testdata/sessions/static-loop-in/18_loop-static-in-positional-payment-timeout.json @@ -209,6 +209,7 @@ " \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n", " \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json index 02b5a3ac4..690c48d2f 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json +++ b/cmd/loop/testdata/sessions/static-loop-in/23_loop-static-in-max-swap-fee-both.json @@ -244,6 +244,7 @@ " \"id\": \"82771323e95dca403d966f70a88be39ef0a475ef6aa78694044ba9b87304ac63\",\n", " \"outpoint\": \"da52bf383c4fe5c684221c311fc5756ccaee211b6c6e6f5ccc159622a6039271:1\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json index f2994fde2..69ff4b8ed 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json +++ b/cmd/loop/testdata/sessions/static-loop-in/24_loop-static-in-max-swap-fee-sat-success.json @@ -235,6 +235,7 @@ " \"id\": \"d8a58536d8472873b9e2e1657468328360fda0b94231cfc5e29900cab735da84\",\n", " \"outpoint\": \"f2280f0f086273be73bde92fd9b982208338a5ecebbe93b83b00c77c4d2f8d1b:0\",\n", " \"state\": \"LOOPING_IN\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"550000\"\n", " }\n", diff --git a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json index 15b5d4e1f..211fc7a8f 100644 --- a/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json +++ b/cmd/loop/testdata/sessions/static/02_loop-static-listwithdrawals.json @@ -73,6 +73,7 @@ " \"id\": \"68262a104c9ec325de6bec37b8e31bd875bbd2f5f0b9ce2da20cf0bd636fc448\",\n", " \"outpoint\": \"edcdab8f0b1138d853a453b8b7a5ac3c694bd53ad38b7ccf062e45f99440e6e6:0\",\n", " \"state\": \"WITHDRAWING\",\n", + " \"static_address\": \"\",\n", " \"swap_hash\": \"\",\n", " \"value\": \"500000\"\n", " }\n", diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 1a66f5526..06f94aa11 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -963,24 +963,13 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context, "deposits: %w", err) } - // TODO(hieblmi): add params to deposit for multi-address - // support. - params, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, fmt.Errorf("unable to retrieve static "+ - "address parameters: %w", err) - } - info, err := s.lnd.Client.GetInfo(ctx) if err != nil { return nil, fmt.Errorf("unable to get lnd info: %w", err) } selectedDeposits, err := loopin.SelectDeposits( - selectedAmount, deposits, params.Expiry, - info.BlockHeight, + selectedAmount, deposits, info.BlockHeight, ) if err != nil { return nil, fmt.Errorf("unable to select deposits: %w", @@ -2061,7 +2050,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, f := func(d *deposit.Deposit) bool { return slices.Contains(outpoints, d.OutPoint.String()) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } if len(outpoints) != len(filteredDeposits) { return nil, fmt.Errorf("not all outpoints found in " + @@ -2077,11 +2069,14 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, return d.IsInState(toServerState(req.StateFilter)) } - filteredDeposits = filter(allDeposits, f) + filteredDeposits, err = s.filterDeposits(allDeposits, f) + if err != nil { + return nil, err + } } // Calculate the blocks until expiry for each deposit. - err = s.populateBlocksUntilExpiry(ctx, filteredDeposits) + err = s.populateBlocksUntilExpiry(ctx, allDeposits, filteredDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -2159,13 +2154,6 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, return nil, err } - addrParams, err := s.staticAddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return nil, err - } - // Fetch all deposits at once and index them by swap hash for a quick // lookup. allDeposits, err := s.depositManager.GetAllDeposits(ctx) @@ -2206,22 +2194,23 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, if ds, ok := depositsBySwap[swp.SwapHash]; ok { protoDeposits = make([]*looprpc.Deposit, 0, len(ds)) for _, d := range ds { - state := toClientDepositState(d.GetState()) confirmationHeight := d.GetConfirmationHeight() + if d.AddressParams == nil { + return nil, fmt.Errorf("missing static "+ + "address parameters for deposit %v", + d.OutPoint) + } blocksUntilExpiry := depositBlocksUntilExpiry( - confirmationHeight, addrParams.Expiry, + confirmationHeight, + d.AddressParams.Expiry, int64(lndInfo.BlockHeight), ) - pd := &looprpc.Deposit{ - Id: d.ID[:], - State: state, - Outpoint: d.OutPoint.String(), - Value: int64(d.Value), - ConfirmationHeight: confirmationHeight, - SwapHash: d.SwapHash[:], - BlocksUntilExpiry: blocksUntilExpiry, + pd, err := s.rpcDeposit(d) + if err != nil { + return nil, err } + pd.BlocksUntilExpiry = blocksUntilExpiry protoDeposits = append(protoDeposits, pd) } } @@ -2403,11 +2392,14 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, } // Build a list of used deposits for the response. - usedDeposits := filter( + usedDeposits, err := s.filterDeposits( loopIn.Deposits, func(d *deposit.Deposit) bool { return true }, ) + if err != nil { + return nil, err + } - err = s.populateBlocksUntilExpiry(ctx, usedDeposits) + err = s.populateBlocksUntilExpiry(ctx, loopIn.Deposits, usedDeposits) if err != nil { infof("Failed to populate blocks until expiry: %v", err) } @@ -2449,21 +2441,31 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, // Calculate the blocks until expiry for each deposit and return the modified // StaticAddressLoopInResponse. func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, - deposits []*looprpc.Deposit) error { + sourceDeposits []*deposit.Deposit, deposits []*looprpc.Deposit) error { lndInfo, err := s.lnd.Client.GetInfo(ctx) if err != nil { return err } - bestBlockHeight := int64(lndInfo.BlockHeight) - params, err := s.staticAddressManager.GetStaticAddressParameters(ctx) - if err != nil { - return err + expiryByOutpoint := make(map[string]uint32, len(sourceDeposits)) + for _, d := range sourceDeposits { + if d.AddressParams == nil { + continue + } + + expiryByOutpoint[d.OutPoint.String()] = d.AddressParams.Expiry } + + bestBlockHeight := int64(lndInfo.BlockHeight) for i := range len(deposits) { + expiry, ok := expiryByOutpoint[deposits[i].Outpoint] + if !ok { + continue + } + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( - deposits[i].ConfirmationHeight, params.Expiry, + deposits[i].ConfirmationHeight, expiry, bestBlockHeight, ) } @@ -2512,35 +2514,65 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context, type filterFunc func(deposits *deposit.Deposit) bool -func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { +func (s *swapClientServer) filterDeposits(deposits []*deposit.Deposit, + f filterFunc) ([]*looprpc.Deposit, error) { + var clientDeposits []*looprpc.Deposit for _, d := range deposits { if !f(d) { continue } - swapHash := make([]byte, 0, len(lntypes.Hash{})) - if d.SwapHash != nil { - swapHash = d.SwapHash[:] - } - - hash := d.Hash - outpoint := wire.NewOutPoint(&hash, d.Index).String() - deposit := &looprpc.Deposit{ - Id: d.ID[:], - State: toClientDepositState( - d.GetState(), - ), - Outpoint: outpoint, - Value: int64(d.Value), - ConfirmationHeight: d.GetConfirmationHeight(), - SwapHash: swapHash, + deposit, err := s.rpcDeposit(d) + if err != nil { + return nil, err } clientDeposits = append(clientDeposits, deposit) } - return clientDeposits + return clientDeposits, nil +} + +func (s *swapClientServer) rpcDeposit(d *deposit.Deposit) ( + *looprpc.Deposit, error) { + + swapHash := make([]byte, 0, len(lntypes.Hash{})) + if d.SwapHash != nil { + swapHash = d.SwapHash[:] + } + + hash := d.Hash + outpoint := wire.NewOutPoint(&hash, d.Index).String() + deposit := &looprpc.Deposit{ + Id: d.ID[:], + State: toClientDepositState( + d.GetState(), + ), + Outpoint: outpoint, + Value: int64(d.Value), + ConfirmationHeight: d.GetConfirmationHeight(), + SwapHash: swapHash, + } + + if d.AddressParams == nil { + return deposit, nil + } + + if s.staticAddressManager == nil { + return nil, fmt.Errorf("static address manager not configured") + } + + staticAddress, err := s.staticAddressManager.GetTaprootAddress( + d.AddressParams.ClientPubkey, d.AddressParams.ServerPubkey, + int64(d.AddressParams.Expiry), + ) + if err != nil { + return nil, err + } + deposit.StaticAddress = staticAddress.String() + + return deposit, nil } func toClientDepositState(state fsm.StateType) looprpc.DepositState { diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb588d5cd..012bcf104 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -339,6 +339,17 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { available.SetState(deposit.Deposited) addrMgr, lnd := newTestStaticAddressContext(t) + addresses, err := addrMgr.GetAllAddresses(context.Background()) + require.NoError(t, err) + require.Len(t, addresses, 1) + available.AddressParams = addresses[0] + + expectedAddr, err := addrMgr.GetTaprootAddress( + addresses[0].ClientPubkey, addresses[0].ServerPubkey, + int64(addresses[0].Expiry), + ) + require.NoError(t, err) + server := &swapClientServer{ depositManager: newTestDepositManager(available), staticAddressManager: addrMgr, @@ -354,6 +365,10 @@ func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) { t, available.OutPoint.String(), resp.FilteredDeposits[0].Outpoint, ) + require.Equal( + t, expectedAddr.String(), + resp.FilteredDeposits[0].StaticAddress, + ) } // TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 69fbcfdd7..55ad2424e 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -414,6 +414,17 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { } testDeposit.SetState(deposit.LoopedIn) + _, clientPubkey := mock_lnd.CreateKey(1) + _, serverPubkey := mock_lnd.CreateKey(2) + staticAddressParams := &script.Parameters{ + ID: 1, + ClientPubkey: clientPubkey, + ServerPubkey: serverPubkey, + Expiry: staticAddressExpiry, + PkScript: []byte("pkscript"), + } + testDeposit.AddressParams = staticAddressParams + initiationTime := time.Unix(1_234, 567).UTC() lastUpdateTime := time.Unix(2_345, 678).UTC() staticLoopIn := &loopin.StaticAddressLoopIn{ @@ -444,15 +455,8 @@ func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { }, 1) require.NoError(t, err) - _, clientPubkey := mock_lnd.CreateKey(1) - _, serverPubkey := mock_lnd.CreateKey(2) addrStore := &mockAddressStore{ - params: []*script.Parameters{{ - ClientPubkey: clientPubkey, - ServerPubkey: serverPubkey, - Expiry: staticAddressExpiry, - PkScript: []byte("pkscript"), - }}, + params: []*script.Parameters{staticAddressParams}, } addrMgr, err := address.NewManager(&address.ManagerConfig{ Store: addrStore, diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index e9c104421..f97a2938a 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -5706,7 +5706,9 @@ type Deposit struct { BlocksUntilExpiry int64 `protobuf:"varint,6,opt,name=blocks_until_expiry,json=blocksUntilExpiry,proto3" json:"blocks_until_expiry,omitempty"` // The swap hash of the swap that this deposit is part of. This field is only // set if the deposit is part of a loop-in swap. - SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + SwapHash []byte `protobuf:"bytes,7,opt,name=swap_hash,json=swapHash,proto3" json:"swap_hash,omitempty"` + // The static address that the deposit was sent to. + StaticAddress string `protobuf:"bytes,8,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5790,6 +5792,13 @@ func (x *Deposit) GetSwapHash() []byte { return nil } +func (x *Deposit) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + type StaticAddressWithdrawal struct { state protoimpl.MessageState `protogen:"open.v1"` // The transaction id of the withdrawal transaction. @@ -7025,7 +7034,7 @@ const file_client_proto_rawDesc = "" + "\x18value_looped_in_satoshis\x18\b \x01(\x03R\x15valueLoopedInSatoshis\x12J\n" + "\"value_htlc_timeout_sweeps_satoshis\x18\t \x01(\x03R\x1evalueHtlcTimeoutSweepsSatoshis\x122\n" + "\x15value_channels_opened\x18\n" + - " \x01(\x03R\x13valueChannelsOpened\"\xf6\x01\n" + + " \x01(\x03R\x13valueChannelsOpened\"\x9d\x02\n" + "\aDeposit\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x12+\n" + "\x05state\x18\x02 \x01(\x0e2\x15.looprpc.DepositStateR\x05state\x12\x1a\n" + @@ -7033,7 +7042,8 @@ const file_client_proto_rawDesc = "" + "\x05value\x18\x04 \x01(\x03R\x05value\x12/\n" + "\x13confirmation_height\x18\x05 \x01(\x03R\x12confirmationHeight\x12.\n" + "\x13blocks_until_expiry\x18\x06 \x01(\x03R\x11blocksUntilExpiry\x12\x1b\n" + - "\tswap_hash\x18\a \x01(\fR\bswapHash\"\xc2\x02\n" + + "\tswap_hash\x18\a \x01(\fR\bswapHash\x12%\n" + + "\x0estatic_address\x18\b \x01(\tR\rstaticAddress\"\xc2\x02\n" + "\x17StaticAddressWithdrawal\x12\x13\n" + "\x05tx_id\x18\x01 \x01(\tR\x04txId\x12,\n" + "\bdeposits\x18\x02 \x03(\v2\x10.looprpc.DepositR\bdeposits\x12A\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index 3f41ac11c..f685836ca 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -2090,6 +2090,11 @@ message Deposit { set if the deposit is part of a loop-in swap. */ bytes swap_hash = 7; + + /* + The static address that the deposit was sent to. + */ + string static_address = 8; } message StaticAddressWithdrawal { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 1aa9036bf..069e1cd93 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1697,6 +1697,10 @@ "type": "string", "format": "byte", "description": "The swap hash of the swap that this deposit is part of. This field is only\nset if the deposit is part of a loop-in swap." + }, + "static_address": { + "type": "string", + "description": "The static address that the deposit was sent to." } } },