From 802afcb962b5597ca975683bac08286cbb6731fc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:15:03 +0200 Subject: [PATCH 01/36] staticaddr/deposit: handle loop-in htlc timeout Keep deposits locked when the server publishes the loop-in HTLC without paying the invoice. This lets the client sweep through the HTLC timeout path instead of making the same outputs available for another action. --- staticaddr/deposit/fsm.go | 5 ++++ staticaddr/deposit/fsm_test.go | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 staticaddr/deposit/fsm_test.go diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 6dadd127f..9e4faeeec 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -353,6 +353,11 @@ func (f *FSM) DepositStatesV0() fsm.States { // still pending, we publish the expiry sweep. OnExpiry: PublishExpirySweep, + // If the server publishes the HTLC without + // paying us, we need to keep the deposit locked + // until the HTLC timeout path can be swept. + OnSweepingHtlcTimeout: SweepHtlcTimeout, + OnLoopInInitiated: LoopingIn, OnRecover: LoopingIn, diff --git a/staticaddr/deposit/fsm_test.go b/staticaddr/deposit/fsm_test.go new file mode 100644 index 000000000..10399bb12 --- /dev/null +++ b/staticaddr/deposit/fsm_test.go @@ -0,0 +1,52 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLoopingInTransitionsToSweepHtlcTimeout verifies that a deposit selected +// by a loop-in can be moved into the timeout sweep state if the server confirms +// the HTLC without paying the invoice. +func TestLoopingInTransitionsToSweepHtlcTimeout(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 9, + } + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(LoopingIn) + + store := new(mockStore) + store.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Once() + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + Store: store, + }, + deposit: deposit, + params: &script.Parameters{Expiry: 1}, + } + depositFSM.StateMachine = fsm.NewStateMachineWithState( + depositFSM.DepositStatesV0(), LoopingIn, DefaultObserverSize, + ) + depositFSM.ActionEntryFunc = depositFSM.updateDeposit + + err := depositFSM.SendEvent( + t.Context(), OnSweepingHtlcTimeout, nil, + ) + require.NoError(t, err) + require.Equal(t, SweepHtlcTimeout, deposit.GetState()) + store.AssertExpectations(t) +} From 402279a5c80c9c318581c15920ea6e43ee4d9258 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:15:40 +0200 Subject: [PATCH 02/36] staticaddr/deposit: ignore expiry blocks in final states Return early when block notifications reach deposits that already moved into a terminal state. This prevents final deposits from retrying expiry handling after recovery or while their FSM is still draining block updates. --- staticaddr/deposit/fsm.go | 4 +++ staticaddr/deposit/fsm_test.go | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 9e4faeeec..01a7edf85 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -241,6 +241,10 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, func (f *FSM) handleBlockNotification(ctx context.Context, currentHeight uint32) { + if f.deposit.IsInFinalState() { + return + } + // If the deposit is expired but not yet sufficiently confirmed, we // republish the expiry sweep transaction. if f.deposit.IsExpired(currentHeight, f.params.Expiry) { diff --git a/staticaddr/deposit/fsm_test.go b/staticaddr/deposit/fsm_test.go index 10399bb12..badac9fdc 100644 --- a/staticaddr/deposit/fsm_test.go +++ b/staticaddr/deposit/fsm_test.go @@ -1,7 +1,9 @@ package deposit import ( + "context" "testing" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -11,6 +13,65 @@ import ( "github.com/stretchr/testify/require" ) +// TestHandleBlockNotificationIgnoresFinalStates verifies that a block-driven +// expiry notification cannot mutate deposits that already reached a final +// state but have not yet been removed from the manager's active set. +func TestHandleBlockNotificationIgnoresFinalStates(t *testing.T) { + t.Parallel() + + finalStates := []fsm.StateType{ + Expired, + Withdrawn, + LoopedIn, + HtlcTimeoutSwept, + ChannelPublished, + } + + for i, state := range finalStates { + t.Run(string(state), func(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{byte(i + 1)}, + Index: uint32(i), + } + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 1, + } + deposit.SetState(state) + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + Store: new(mockStore), + }, + deposit: deposit, + params: &script.Parameters{Expiry: 1}, + quitChan: make(chan struct{}), + finalizedDepositChan: make(chan wire.OutPoint, 1), + } + depositFSM.StateMachine = fsm.NewStateMachineWithState( + depositFSM.DepositStatesV0(), state, + DefaultObserverSize, + ) + depositFSM.ActionEntryFunc = depositFSM.updateDeposit + + depositFSM.handleBlockNotification(context.Background(), 3) + + require.Never(t, func() bool { + return deposit.GetState() != state + }, 100*time.Millisecond, 10*time.Millisecond) + + select { + case finalized := <-depositFSM.finalizedDepositChan: + t.Fatalf("unexpected finalization for %v", finalized) + + default: + } + }) + } +} + // TestLoopingInTransitionsToSweepHtlcTimeout verifies that a deposit selected // by a loop-in can be moved into the timeout sweep state if the server confirms // the HTLC without paying the invoice. From d6057d3870bed92716d93f735a9203d6b0738eed Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:16:34 +0200 Subject: [PATCH 03/36] staticaddr/deposit: reject duplicate outpoints Reject duplicate static-address deposit outpoints before creating withdrawal, loop-in, or channel-open requests. Use the shared outpoint duplicate helper so each flow reports the same input validation failure. --- staticaddr/deposit/manager.go | 8 +++ staticaddr/deposit/manager_reconcile_test.go | 58 ++++++++++++++++++++ staticaddr/openchannel/manager.go | 12 ++-- staticaddr/outpoint/duplicates.go | 24 ++++++++ staticaddr/outpoint/duplicates_test.go | 44 +++++++++++++++ staticaddr/staticutil/utils.go | 20 ++++--- 6 files changed, 150 insertions(+), 16 deletions(-) create mode 100644 staticaddr/deposit/manager_reconcile_test.go create mode 100644 staticaddr/outpoint/duplicates.go create mode 100644 staticaddr/outpoint/duplicates_test.go diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index af8820302..aacd55cfd 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/outpoint" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" ) @@ -439,6 +440,10 @@ func (m *Manager) GetActiveDepositsInState(stateFilter fsm.StateType) ( func (m *Manager) AllOutpointsActiveDeposits(outpoints []wire.OutPoint, targetState fsm.StateType) ([]*Deposit, bool) { + if outpoint.HasDuplicates(outpoints) { + return nil, false + } + m.mu.Lock() defer m.mu.Unlock() @@ -497,6 +502,9 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, for i, d := range deposits { outpoints[i] = d.OutPoint } + if outpoint.HasDuplicates(outpoints) { + return fmt.Errorf("duplicate deposit outpoint") + } m.mu.Lock() stateMachines, _ := m.toActiveDeposits(&outpoints) diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go new file mode 100644 index 000000000..eb68acb8e --- /dev/null +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -0,0 +1,58 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +// TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a +// duplicated selection is rejected before the manager tries to lock the same +// deposit twice. +func TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints(t *testing.T) { + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + Index: 6, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + manager := NewManager(&ManagerConfig{}) + manager.deposits[outpoint] = deposit + manager.activeDeposits[outpoint] = &FSM{ + deposit: deposit, + } + + deposits, ok := manager.AllOutpointsActiveDeposits( + []wire.OutPoint{outpoint, outpoint}, Deposited, + ) + require.False(t, ok) + require.Nil(t, deposits) +} + +// TestTransitionDepositsRejectsDuplicateOutpoints verifies that transition +// callers cannot deadlock the manager by passing the same deposit twice. +func TestTransitionDepositsRejectsDuplicateOutpoints(t *testing.T) { + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + Index: 6, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Deposited) + + manager := NewManager(&ManagerConfig{}) + err := manager.TransitionDeposits( + t.Context(), []*Deposit{deposit, deposit}, OnLoopInInitiated, + LoopingIn, + ) + require.ErrorContains(t, err, "duplicate deposit outpoint") + require.Equal(t, Deposited, deposit.GetState()) +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 17f6c6a11..92d9276c0 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -17,6 +17,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/outpoint" "github.com/lightninglabs/loop/staticaddr/staticutil" "github.com/lightninglabs/loop/staticaddr/withdraw" "github.com/lightningnetwork/lnd/lnrpc" @@ -284,13 +285,10 @@ func (m *Manager) OpenChannel(ctx context.Context, // Check for duplicate outpoints which would lead to fee // miscalculation and an invalid PSBT with the same input // listed twice. - seen := make(map[wire.OutPoint]struct{}, len(outpoints)) - for _, op := range outpoints { - if _, ok := seen[op]; ok { - return nil, fmt.Errorf("duplicate outpoint "+ - "%v in request", op) - } - seen[op] = struct{}{} + duplicate, ok := outpoint.FirstDuplicate(outpoints) + if ok { + return nil, fmt.Errorf("duplicate outpoint %v in "+ + "request", duplicate) } deposits, allActive = diff --git a/staticaddr/outpoint/duplicates.go b/staticaddr/outpoint/duplicates.go new file mode 100644 index 000000000..d8b7c3367 --- /dev/null +++ b/staticaddr/outpoint/duplicates.go @@ -0,0 +1,24 @@ +package outpoint + +import "github.com/btcsuite/btcd/wire" + +// FirstDuplicate returns the first repeated outpoint in the given list. +func FirstDuplicate(outpoints []wire.OutPoint) (wire.OutPoint, bool) { + seen := make(map[wire.OutPoint]struct{}, len(outpoints)) + for _, outpoint := range outpoints { + if _, ok := seen[outpoint]; ok { + return outpoint, true + } + + seen[outpoint] = struct{}{} + } + + return wire.OutPoint{}, false +} + +// HasDuplicates returns true if the given list contains a repeated outpoint. +func HasDuplicates(outpoints []wire.OutPoint) bool { + _, ok := FirstDuplicate(outpoints) + + return ok +} diff --git a/staticaddr/outpoint/duplicates_test.go b/staticaddr/outpoint/duplicates_test.go new file mode 100644 index 000000000..c8bfae55b --- /dev/null +++ b/staticaddr/outpoint/duplicates_test.go @@ -0,0 +1,44 @@ +package outpoint + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stretchr/testify/require" +) + +func TestFirstDuplicate(t *testing.T) { + duplicate := wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 2, + } + + outpoints := []wire.OutPoint{{ + Hash: chainhash.Hash{3}, + Index: 4, + }, duplicate, { + Hash: chainhash.Hash{5}, + Index: 6, + }, duplicate} + + got, ok := FirstDuplicate(outpoints) + require.True(t, ok) + require.Equal(t, duplicate, got) + require.True(t, HasDuplicates(outpoints)) +} + +func TestFirstDuplicateNoDuplicate(t *testing.T) { + outpoints := []wire.OutPoint{{ + Hash: chainhash.Hash{1}, + Index: 2, + }, { + Hash: chainhash.Hash{3}, + Index: 4, + }} + + got, ok := FirstDuplicate(outpoints) + require.False(t, ok) + require.Equal(t, wire.OutPoint{}, got) + require.False(t, HasDuplicates(outpoints)) +} diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index a8a5e4041..583845f7d 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/outpoint" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -24,20 +25,21 @@ import ( func ToPrevOuts(deposits []*deposit.Deposit, pkScript []byte) (map[wire.OutPoint]*wire.TxOut, error) { + outpoints := make([]wire.OutPoint, len(deposits)) + for i, d := range deposits { + outpoints[i] = d.OutPoint + } + if duplicate, ok := outpoint.FirstDuplicate(outpoints); ok { + return nil, fmt.Errorf("duplicate outpoint %v", duplicate) + } + prevOuts := make(map[wire.OutPoint]*wire.TxOut, len(deposits)) - for _, d := range deposits { - outpoint := wire.OutPoint{ - Hash: d.Hash, - Index: d.Index, - } + for i, d := range deposits { + outpoint := outpoints[i] txOut := &wire.TxOut{ Value: int64(d.Value), PkScript: pkScript, } - if _, ok := prevOuts[outpoint]; ok { - return nil, fmt.Errorf("duplicate outpoint %v", - outpoint) - } prevOuts[outpoint] = txOut } From 928d1b28b7164fd34b84bcf13a446feffef357dc Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:17:14 +0200 Subject: [PATCH 04/36] staticaddr/loopin: handle closed invoice updates Treat closed invoice update channels as terminal for the monitor loop. This avoids spinning when lnd closes the subscription after invoice cancellation or shutdown. --- staticaddr/loopin/actions.go | 63 ++++++++++++++++--------- staticaddr/loopin/actions_test.go | 77 +++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index cf21fc5fe..42ac7f1e7 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -359,6 +359,35 @@ func (f *FSM) cancelSwapInvoice() { } } +// handleInvoiceUpdate applies the monitor state's invoice-update semantics and +// reports whether the update produced a terminal event. +func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( + fsm.EventType, bool) { + + switch update.State { + case invoices.ContractOpen: + return fsm.NoOp, false + + case invoices.ContractAccepted: + return fsm.NoOp, false + + case invoices.ContractSettled: + f.Debugf("received off-chain payment update %v", update.State) + return OnPaymentReceived, true + + case invoices.ContractCanceled: + // If the invoice was canceled we only log here since we still need + // to monitor until the htlc timed out. + log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash) + 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 + } +} + // 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. @@ -736,32 +765,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) - case update := <-invoiceUpdateChan: - switch update.State { - case invoices.ContractOpen: - case invoices.ContractAccepted: - case invoices.ContractSettled: - f.Debugf("received off-chain payment update "+ - "%v", update.State) - - return OnPaymentReceived - - case invoices.ContractCanceled: - // If the invoice was canceled we only log here - // since we still need to monitor until the htlc - // timed out. - log.Warnf("invoice for swap hash %v canceled", - f.loopIn.SwapHash) + case update, ok := <-invoiceUpdateChan: + if !ok { + invoiceUpdateChan = nil + continue + } - default: - err = fmt.Errorf("unexpected invoice state %v "+ - "for swap hash %v canceled", - update.State, f.loopIn.SwapHash) + if event, done := f.handleInvoiceUpdate(update); done { + return event + } - return f.HandleError(err) + case err, ok := <-invoiceErrChan: + if !ok { + invoiceErrChan = nil + continue } - case err = <-invoiceErrChan: f.Errorf("invoice subscription error: %v", err) case <-ctx.Done(): diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index d75adf6cc..64e2bcde1 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -3,6 +3,7 @@ package loopin import ( "context" "errors" + "fmt" "testing" "time" @@ -24,6 +25,82 @@ import ( "google.golang.org/grpc" ) +func TestHandleInvoiceUpdate(t *testing.T) { + t.Parallel() + + swapHash := lntypes.Hash{1, 2, 3} + tests := []struct { + name string + state invoices.ContractState + event fsm.EventType + done bool + errString string + }{ + { + name: "open", + state: invoices.ContractOpen, + event: fsm.NoOp, + }, + { + name: "accepted", + state: invoices.ContractAccepted, + event: fsm.NoOp, + }, + { + name: "settled", + state: invoices.ContractSettled, + event: OnPaymentReceived, + done: true, + }, + { + name: "canceled", + state: invoices.ContractCanceled, + event: fsm.NoOp, + }, + { + name: "unexpected", + state: invoices.ContractState(99), + event: fsm.OnError, + done: true, + errString: "unexpected invoice state", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + loopIn: &StaticAddressLoopIn{ + SwapHash: swapHash, + }, + } + + event, done := f.handleInvoiceUpdate( + lndclient.InvoiceUpdate{ + Invoice: lndclient.Invoice{ + State: test.state, + }, + }, + ) + require.Equal(t, test.event, event) + require.Equal(t, test.done, done) + + if test.errString == "" { + require.Nil(t, f.LastActionError) + } else { + require.ErrorContains( + t, f.LastActionError, test.errString, + ) + require.ErrorContains( + t, f.LastActionError, fmt.Sprint(swapHash), + ) + } + }) + } +} + // 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 From 257d68fc8a6757af0a0ba78c35c61962789f32f5 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 11:55:34 +0200 Subject: [PATCH 05/36] staticaddr/deposit: stop removed fsms Add an explicit Stop method for deposit FSM block-notification loops. Call it when the manager removes a finalized active deposit so stale FSM goroutines stop consuming block updates. --- staticaddr/deposit/fsm.go | 22 ++++++++++++++++++++++ staticaddr/deposit/manager.go | 18 +++++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 01a7edf85..ff5ed01b5 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -160,12 +161,18 @@ type FSM struct { blockNtfnChan chan uint32 + // stopChan requests shutdown of the block notification loop. + stopChan chan struct{} + // quitChan stops after the FSM stops consuming blockNtfnChan. quitChan chan struct{} // finalizedDepositChan is used to signal that the deposit has been // finalized and the FSM can be removed from the manager's memory. finalizedDepositChan chan wire.OutPoint + + // stopOnce ensures Stop is idempotent. + stopOnce sync.Once } // NewFSM creates a new state machine that can action on all static address @@ -191,6 +198,7 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, params: params, address: address, blockNtfnChan: make(chan uint32), + stopChan: make(chan struct{}), quitChan: make(chan struct{}), finalizedDepositChan: finalizedDepositChan, } @@ -226,6 +234,9 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, ctx, currentHeight, ) + case <-fsm.stopChan: + return + case <-ctx.Done(): return } @@ -235,6 +246,17 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, return depoFsm, nil } +// Stop requests shutdown of the FSM's block notification loop. +func (f *FSM) Stop() { + if f == nil || f.stopChan == nil { + return + } + + f.stopOnce.Do(func() { + close(f.stopChan) + }) +} + // handleBlockNotification inspects the current block height and sends the // OnExpiry event to publish the expiry sweep transaction if the deposit timed // out, or it republishes the expiry sweep transaction if it was not yet swept. diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index aacd55cfd..7bef7cf2f 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -147,9 +147,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { case outpoint := <-m.finalizedDepositChan: // If deposits notify us about their finalization, flush // the finalized deposit from memory. - m.mu.Lock() - delete(m.activeDeposits, outpoint) - m.mu.Unlock() + m.removeActiveDeposit(outpoint) case err = <-newBlockErrChan: return err @@ -545,6 +543,20 @@ func unlockDeposits(deposits []*Deposit) { } } +// removeActiveDeposit removes and stops the FSM for an active outpoint. +func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) { + m.mu.Lock() + fsm, ok := m.activeDeposits[outpoint] + if ok { + delete(m.activeDeposits, outpoint) + } + m.mu.Unlock() + + if ok { + fsm.Stop() + } +} + // GetAllDeposits returns all active deposits. func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) { return m.cfg.Store.AllDeposits(ctx) From e9c8ff6c62a3d1c1ff1db5d6a2531a710c7e0539 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 13:20:25 +0200 Subject: [PATCH 06/36] staticaddr/loopin: preserve selected deposit outpoints Store an independent snapshot of the outpoints selected for a static loop-in. Recovered swaps remain tied to the original funding outputs even if deposit records later change confirmation or replacement metadata. Avoid decoding an empty database outpoint string as a synthetic outpoint. --- staticaddr/loopin/loopin.go | 2 - staticaddr/loopin/manager.go | 8 +++- staticaddr/loopin/sql_store.go | 9 ++-- staticaddr/loopin/sql_store_test.go | 73 +++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 0be2ffe44..8cf27e32c 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -93,8 +93,6 @@ type StaticAddressLoopIn struct { // The outpoints in the format txid:vout that are part of the loop-in // swap. - // TODO(hieblmi): Replace this with a getter method that fetches the - // outpoints from the deposits. DepositOutpoints []string // SelectedAmount is the amount that the user selected for the swap. If diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index a76cbba39..06ba43d6e 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -754,8 +754,12 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } swap := &StaticAddressLoopIn{ - SelectedAmount: req.SelectedAmount, - DepositOutpoints: selectedOutpoints, + SelectedAmount: req.SelectedAmount, + // Copy into a nil slice so the swap owns a stable snapshot + // instead of aliasing the caller's selectedOutpoints slice. + DepositOutpoints: append( + []string(nil), selectedOutpoints..., + ), Deposits: selectedDeposits, Label: req.Label, Initiator: req.Initiator, diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index d8c253be5..e90d1bdfa 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -507,9 +507,12 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, } } - depositOutpoints := strings.Split( - swap.DepositOutpoints, OutpointSeparator, - ) + var depositOutpoints []string + if swap.DepositOutpoints != "" { + depositOutpoints = strings.Split( + swap.DepositOutpoints, OutpointSeparator, + ) + } timeoutAddressString := swap.HtlcTimeoutSweepAddress var timeoutAddress btcutil.Address diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 81fd10a76..f0c109684 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -377,3 +377,76 @@ func TestCreateLoopIn(t *testing.T) { time.Microsecond, ) } + +// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins +// keep the original outpoint snapshot stored when the swap was created. +func TestGetLoopInByHashPreservesStoredDepositOutpoints(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) + + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d}, + Index: 0, + } + currentOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0x5a, 0x6b, 0x7c, 0x8d}, + Index: 1, + } + + d := &deposit.Deposit{ + ID: depositID, + OutPoint: oldOutpoint, + 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{0x1, 0x2, 0x3, 0x4} + swap := StaticAddressLoopIn{ + SwapHash: swapHash, + SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, + DepositOutpoints: []string{oldOutpoint.String()}, + Deposits: []*deposit.Deposit{d}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcTimeoutSweepAddress: addr, + } + swap.SetState(SignHtlcTx) + + require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap)) + + d.OutPoint = currentOutpoint + d.ConfirmationHeight = 42 + require.NoError(t, depositStore.UpdateDeposit(ctxb, d)) + + storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash) + require.NoError(t, err) + require.Equal( + t, []string{oldOutpoint.String()}, + storedSwap.DepositOutpoints, + ) + require.Len(t, storedSwap.Deposits, 1) + require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint) + require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight) +} From 8cb83605eba1b7225934061e01aaeecc5e0cdb4a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 13:20:45 +0200 Subject: [PATCH 07/36] staticaddr/loopin: add lnd txout checker Add a TxOutChecker interface for checking whether a selected deposit outpoint is still available before signing the HTLC transaction. Back the implementation with lnd wallet transaction data so known confirmed and mempool spends mark the outpoint unavailable. --- staticaddr/loopin/interface.go | 11 +++ staticaddr/loopin/txout_checker.go | 72 ++++++++++++++++++ staticaddr/loopin/txout_checker_test.go | 98 +++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 staticaddr/loopin/txout_checker.go create mode 100644 staticaddr/loopin/txout_checker_test.go diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index c4bbb2b75..6197d733b 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -4,6 +4,7 @@ import ( "context" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/deposit" @@ -105,6 +106,16 @@ type QuoteGetter interface { numDeposits uint32, fast bool) (*loop.LoopInQuote, error) } +// TxOutChecker checks whether an outpoint is still available in the chain +// backend's UTXO view. +type TxOutChecker interface { + // GetTxOut returns nil if the outpoint is unavailable or spent. The + // includeMempool flag must be passed through to the underlying chain + // backend. + GetTxOut(ctx context.Context, outpoint wire.OutPoint, + includeMempool bool) (*wire.TxOut, error) +} + type NotificationManager interface { // SubscribeStaticLoopInSweepRequests subscribes to the static loop in // sweep requests. These are sent by the server to the client to request diff --git a/staticaddr/loopin/txout_checker.go b/staticaddr/loopin/txout_checker.go new file mode 100644 index 000000000..61463cc88 --- /dev/null +++ b/staticaddr/loopin/txout_checker.go @@ -0,0 +1,72 @@ +package loopin + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" +) + +// lndTxOutChecker checks outpoint availability using lnd's wallet transaction +// view. It returns nil for outputs already spent by a wallet-known transaction. +type lndTxOutChecker struct { + client lndclient.LightningClient +} + +// NewLndTxOutChecker creates a TxOutChecker backed by lnd. +func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker { + return &lndTxOutChecker{ + client: client, + } +} + +// GetTxOut returns the tx output if lnd's transaction view still reports the +// outpoint as unspent. +func (c *lndTxOutChecker) GetTxOut(ctx context.Context, + outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { + + endHeight := int32(0) + if includeMempool { + endHeight = -1 + } + + // We need lnd's wallet transaction view rather than only the funding + // transaction: a matching previous outpoint tells us the deposit has + // already been spent by a wallet-known transaction. When mempool spends + // matter, lnd exposes them through ListTransactions with endHeight=-1. + txs, err := c.client.ListTransactions(ctx, 0, endHeight) + if err != nil { + return nil, err + } + + outpointStr := outpoint.String() + for _, tx := range txs { + for _, prevOutpoint := range tx.PreviousOutpoints { + if prevOutpoint.GetOutpoint() == outpointStr { + return nil, nil + } + } + } + + for _, tx := range txs { + if tx.Tx == nil { + continue + } + + txHash := tx.TxHash + if txHash == "" { + txHash = tx.Tx.TxHash().String() + } + if txHash != outpoint.Hash.String() { + continue + } + + if int(outpoint.Index) >= len(tx.Tx.TxOut) { + return nil, nil + } + + return tx.Tx.TxOut[outpoint.Index], nil + } + + return nil, nil +} diff --git a/staticaddr/loopin/txout_checker_test.go b/staticaddr/loopin/txout_checker_test.go new file mode 100644 index 000000000..8e695e665 --- /dev/null +++ b/staticaddr/loopin/txout_checker_test.go @@ -0,0 +1,98 @@ +package loopin + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/require" +) + +func TestLndTxOutChecker(t *testing.T) { + fundingTx := wire.NewMsgTx(2) + fundingTx.AddTxOut(wire.NewTxOut(1000, []byte{0x01})) + fundingTx.AddTxOut(wire.NewTxOut(2000, []byte{0x02})) + + outpoint := wire.OutPoint{ + Hash: fundingTx.TxHash(), + Index: 1, + } + + t.Run("returns live tx output", func(t *testing.T) { + client := &mockTxListLightningClient{ + txs: []lndclient.Transaction{{ + Tx: fundingTx, + }}, + } + + checker := NewLndTxOutChecker(client) + txOut, err := checker.GetTxOut(t.Context(), outpoint, false) + require.NoError(t, err) + require.Equal(t, fundingTx.TxOut[outpoint.Index], txOut) + require.Equal(t, []txListCall{{ + startHeight: 0, + endHeight: 0, + }}, client.calls) + }) + + t.Run("returns nil for known spend", func(t *testing.T) { + client := &mockTxListLightningClient{ + txs: []lndclient.Transaction{{ + Tx: fundingTx, + }, { + PreviousOutpoints: []*lnrpc.PreviousOutPoint{{ + Outpoint: outpoint.String(), + }}, + }}, + } + + checker := NewLndTxOutChecker(client) + txOut, err := checker.GetTxOut(t.Context(), outpoint, true) + require.NoError(t, err) + require.Nil(t, txOut) + require.Equal(t, []txListCall{{ + startHeight: 0, + endHeight: -1, + }}, client.calls) + }) + + t.Run("returns error", func(t *testing.T) { + expectedErr := errors.New("list transactions failed") + client := &mockTxListLightningClient{ + err: expectedErr, + } + + checker := NewLndTxOutChecker(client) + txOut, err := checker.GetTxOut(t.Context(), outpoint, false) + require.ErrorIs(t, err, expectedErr) + require.Nil(t, txOut) + }) +} + +type txListCall struct { + startHeight int32 + endHeight int32 +} + +type mockTxListLightningClient struct { + lndclient.LightningClient + + txs []lndclient.Transaction + err error + calls []txListCall +} + +func (m *mockTxListLightningClient) ListTransactions(_ context.Context, + startHeight, endHeight int32, _ ...lndclient.ListTransactionsOption) ( + []lndclient.Transaction, error) { + + m.calls = append(m.calls, txListCall{ + startHeight: startHeight, + endHeight: endHeight, + }) + + return m.txs, m.err +} From 544bba1f3cb32f1a8488ecc2403c67a9d1d624c2 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:05:14 +0200 Subject: [PATCH 08/36] staticaddr/deposit: document lock ordering Document the lock-order invariant between Manager.mu and individual deposit locks. Later changes need both locks in the same path, so make the rule explicit before the locking surface grows. --- staticaddr/deposit/deposit.go | 4 ++++ staticaddr/deposit/manager.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 4cb64bc95..2121d1708 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -29,6 +29,10 @@ func (r *ID) FromByteSlice(b []byte) error { // Deposit bundles an utxo at a static address together with manager-relevant // data. +// +// Lock order: if both Manager.mu and a Deposit lock are needed, acquire +// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a +// Deposit lock. type Deposit struct { sync.Mutex diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 7bef7cf2f..495557548 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -59,6 +59,10 @@ type ManagerConfig struct { } // Manager manages the address state machines. +// +// Lock order: if both Manager.mu and a Deposit lock are needed, acquire +// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a +// Deposit lock. type Manager struct { cfg *ManagerConfig From c0bb49b605909451fed2433099d92b6e112168d8 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:06:11 +0200 Subject: [PATCH 09/36] staticaddr/deposit: factor active deposit notifications Move active-deposit block notification fan-out into a helper. This keeps the event loop small and gives later startup replay logic a single path for notifying recovered deposit FSMs. --- staticaddr/deposit/manager.go | 48 ++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 495557548..c8fd370b6 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -128,24 +128,9 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { for { select { case height := <-newBlockChan: - // Inform all active deposits about a new block arrival. - m.mu.Lock() - activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) - for _, fsm := range m.activeDeposits { - activeDeposits = append(activeDeposits, fsm) - } - m.mu.Unlock() - - for _, fsm := range activeDeposits { - select { - case fsm.blockNtfnChan <- uint32(height): - - case <-fsm.quitChan: - continue - - case <-ctx.Done(): - return ctx.Err() - } + err := m.notifyActiveDeposits(ctx, uint32(height)) + if err != nil { + return err } case outpoint := <-m.finalizedDepositChan: @@ -162,6 +147,33 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } +// notifyActiveDeposits informs all active deposit FSMs about a new block +// height. +func (m *Manager) notifyActiveDeposits(ctx context.Context, + height uint32) error { + + m.mu.Lock() + activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) + for _, fsm := range m.activeDeposits { + activeDeposits = append(activeDeposits, fsm) + } + m.mu.Unlock() + + for _, fsm := range activeDeposits { + select { + case fsm.blockNtfnChan <- height: + + case <-fsm.quitChan: + continue + + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + // 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 { From d015edc48ccc79dea93616dfd1f0e3bed891117a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:06:25 +0200 Subject: [PATCH 10/36] staticaddr/deposit: serialize deposit reconciliation Guard reconcileDeposits with a dedicated mutex. Polling and block-driven reconciliation can overlap, so serialize the path before it updates confirmation data and active FSM state. --- staticaddr/deposit/manager.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index c8fd370b6..cbd8a5b8c 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -69,6 +69,10 @@ 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 sync.Mutex + // activeDeposits contains all the active static address outputs. activeDeposits map[wire.OutPoint]*FSM @@ -251,6 +255,9 @@ func (m *Manager) pollDeposits(ctx context.Context) { // far. It picks the newly identified deposits and starts a state machine per // deposit to track its progress. func (m *Manager) reconcileDeposits(ctx context.Context) error { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + log.Tracef("Reconciling new deposits...") utxos, err := m.cfg.AddressManager.ListUnspent( From c6092c850f2bf42c5348f2c85aa074f04ff19e03 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:06:50 +0200 Subject: [PATCH 11/36] staticaddr/deposit: reject invalid transitions Reject nil deposits and final-state deposits before sending FSM events. This keeps callers from transitioning stale or completed deposits and uses the no-lock state helper while deposits are already locked. --- staticaddr/deposit/deposit.go | 4 ++++ staticaddr/deposit/manager.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 2121d1708..46d5c6264 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -73,6 +73,10 @@ func (d *Deposit) IsInFinalState() bool { d.Lock() defer d.Unlock() + return d.isInFinalStateNoLock() +} + +func (d *Deposit) isInFinalStateNoLock() bool { return d.state == Expired || d.state == Withdrawn || d.state == LoopedIn || d.state == HtlcTimeoutSwept || d.state == ChannelPublished diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index cbd8a5b8c..2294f55de 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -521,6 +521,10 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, outpoints := make([]wire.OutPoint, len(deposits)) for i, d := range deposits { + if d == nil { + return fmt.Errorf("nil deposit at index %d", i) + } + outpoints[i] = d.OutPoint } if outpoint.HasDuplicates(outpoints) { @@ -537,6 +541,13 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, lockDeposits(deposits) defer unlockDeposits(deposits) + for _, deposit := range deposits { + if deposit.isInFinalStateNoLock() { + return fmt.Errorf("deposit %v is no longer active in "+ + "state %v", deposit.OutPoint, deposit.state) + } + } + for _, sm := range stateMachines { err := sm.SendEvent(ctx, event, nil) if err != nil { From 67d54d912e71f8f1e7837f9dd81e85c04c1f4621 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:08:06 +0200 Subject: [PATCH 12/36] staticaddr/loopin: default payment timeout duration Add a duration helper that falls back to the default payment timeout. Recovered legacy swaps can have a zero persisted timeout, so later deadline logic can use this without treating zero as immediate expiry. --- staticaddr/loopin/loopin.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 8cf27e32c..6c1860bdb 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -467,12 +467,28 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { // 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 swaps configured payment +// initiation time of the swap. If more than the swap's configured payment // timeout has passed, the remaining time will be negative. func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 { elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds() - return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation) + return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation) +} + +// PaymentTimeoutDuration returns the configured payment timeout duration, +// falling back to the default if the swap predates the persisted timeout field. +func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration { + return time.Duration(l.paymentTimeoutSeconds()) * time.Second +} + +// paymentTimeoutSeconds returns the configured timeout in seconds. +func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 { + timeoutSeconds := int64(l.PaymentTimeoutSeconds) + if timeoutSeconds == 0 { + timeoutSeconds = int64(DefaultPaymentTimeoutSeconds) + } + + return timeoutSeconds } // Outpoints returns the wire outpoints of the deposits. From f1c55b5b1746e211a16c7908a57494ad6cbe028c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 13:16:49 +0200 Subject: [PATCH 13/36] staticaddr/deposit: ignore queued expiry in final states A block notification can queue OnExpiry before a deposit reaches a final state. If the final transition wins that race first, the stale expiry event must not overwrite the terminal outcome. Keep LoopedIn and Withdrawn as self-loops on OnExpiry, matching the other final states. Add a focused FSM test that sends OnExpiry directly to each final state and verifies the state is preserved. --- staticaddr/deposit/fsm.go | 4 +-- staticaddr/deposit/fsm_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index ff5ed01b5..dad14d333 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -393,7 +393,7 @@ func (f *FSM) DepositStatesV0() fsm.States { }, LoopedIn: fsm.State{ Transitions: fsm.Transitions{ - OnExpiry: Expired, + OnExpiry: LoopedIn, }, Action: f.FinalizeDepositAction, }, @@ -412,7 +412,7 @@ func (f *FSM) DepositStatesV0() fsm.States { }, Withdrawn: fsm.State{ Transitions: fsm.Transitions{ - OnExpiry: Expired, + OnExpiry: Withdrawn, OnWithdrawn: Withdrawn, }, Action: f.FinalizeDepositAction, diff --git a/staticaddr/deposit/fsm_test.go b/staticaddr/deposit/fsm_test.go index badac9fdc..f121d7c28 100644 --- a/staticaddr/deposit/fsm_test.go +++ b/staticaddr/deposit/fsm_test.go @@ -72,6 +72,53 @@ func TestHandleBlockNotificationIgnoresFinalStates(t *testing.T) { } } +// TestFinalStatesIgnoreQueuedExpiry verifies that a queued OnExpiry event cannot +// overwrite a deposit that already reached a final state. +func TestFinalStatesIgnoreQueuedExpiry(t *testing.T) { + t.Parallel() + + finalStates := []fsm.StateType{ + Expired, + Withdrawn, + LoopedIn, + HtlcTimeoutSwept, + ChannelPublished, + } + + for i, state := range finalStates { + t.Run(string(state), func(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{byte(i + 1)}, + Index: uint32(i), + } + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(state) + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + Store: new(mockStore), + }, + deposit: deposit, + quitChan: make(chan struct{}), + finalizedDepositChan: make(chan wire.OutPoint, 1), + } + depositFSM.StateMachine = fsm.NewStateMachineWithState( + depositFSM.DepositStatesV0(), state, + DefaultObserverSize, + ) + depositFSM.ActionEntryFunc = depositFSM.updateDeposit + + err := depositFSM.SendEvent(t.Context(), OnExpiry, nil) + require.NoError(t, err) + require.Equal(t, state, deposit.GetState()) + }) + } +} + // TestLoopingInTransitionsToSweepHtlcTimeout verifies that a deposit selected // by a loop-in can be moved into the timeout sweep state if the server confirms // the HTLC without paying the invoice. From 2abb1268f040121d7a5d72b993cd2238393a102b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 18:33:31 +0200 Subject: [PATCH 14/36] staticaddr/deposit: guard confirmation height access Document deposit lock ownership for mutable confirmation state and route production reads through deposit accessors. Keep store persistence on no-lock helpers while callers hold the deposit lock, preserving the existing transition behavior without leaving direct field reads in user-facing paths. --- loopd/swapclient_server.go | 9 +++++---- staticaddr/deposit/actions.go | 2 +- staticaddr/deposit/deposit.go | 21 +++++++++++++++++++++ staticaddr/deposit/fsm.go | 15 ++++++--------- staticaddr/deposit/manager.go | 10 +++++++--- staticaddr/deposit/sql_store.go | 10 +++++++--- staticaddr/loopin/autoloop_dp.go | 6 +++--- staticaddr/loopin/manager.go | 7 ++++--- staticaddr/withdraw/manager.go | 2 +- 9 files changed, 55 insertions(+), 27 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 86d44a52a..7bf5ab533 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1896,7 +1896,7 @@ func (s *swapClientServer) ListStaticAddressWithdrawals(ctx context.Context, Id: d.ID[:], Outpoint: d.OutPoint.String(), Value: int64(d.Value), - ConfirmationHeight: d.ConfirmationHeight, + ConfirmationHeight: d.GetConfirmationHeight(), State: toClientDepositState( d.GetState(), ), @@ -1987,7 +1987,8 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, protoDeposits = make([]*looprpc.Deposit, 0, len(ds)) for _, d := range ds { state := toClientDepositState(d.GetState()) - blocksUntilExpiry := d.ConfirmationHeight + + confirmationHeight := d.GetConfirmationHeight() + blocksUntilExpiry := confirmationHeight + int64(addrParams.Expiry) - int64(lndInfo.BlockHeight) @@ -1996,7 +1997,7 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context, State: state, Outpoint: d.OutPoint.String(), Value: int64(d.Value), - ConfirmationHeight: d.ConfirmationHeight, + ConfirmationHeight: confirmationHeight, SwapHash: d.SwapHash[:], BlocksUntilExpiry: blocksUntilExpiry, } @@ -2304,7 +2305,7 @@ func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { ), Outpoint: outpoint, Value: int64(d.Value), - ConfirmationHeight: d.ConfirmationHeight, + ConfirmationHeight: d.GetConfirmationHeight(), SwapHash: swapHash, } diff --git a/staticaddr/deposit/actions.go b/staticaddr/deposit/actions.go index 437c3ac2a..77560eb24 100644 --- a/staticaddr/deposit/actions.go +++ b/staticaddr/deposit/actions.go @@ -139,7 +139,7 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context, spendChan, errSpendChan, err := f.cfg.ChainNotifier.RegisterConfirmationsNtfn( //nolint:lll ctx, txID, f.deposit.TimeOutSweepPkScript, DefaultConfTarget, - int32(f.deposit.ConfirmationHeight), + int32(f.deposit.GetConfirmationHeight()), ) if err != nil { return f.HandleError(err) diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 46d5c6264..3a1afc4a9 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -33,6 +33,9 @@ func (r *ID) FromByteSlice(b []byte) error { // Lock order: if both Manager.mu and a Deposit lock are needed, acquire // Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a // Deposit lock. +// +// The state and ConfirmationHeight fields are mutable and protected by the +// deposit lock. type Deposit struct { sync.Mutex @@ -96,6 +99,10 @@ func (d *Deposit) GetState() fsm.StateType { return d.state } +func (d *Deposit) GetStateNoLock() fsm.StateType { + return d.state +} + func (d *Deposit) SetState(state fsm.StateType) { d.Lock() defer d.Unlock() @@ -118,6 +125,20 @@ func (d *Deposit) IsInStateNoLock(state fsm.StateType) bool { return d.state == state } +// GetConfirmationHeight returns the deposit confirmation height. +func (d *Deposit) GetConfirmationHeight() int64 { + d.Lock() + defer d.Unlock() + + return d.ConfirmationHeight +} + +// GetConfirmationHeightNoLock returns the deposit confirmation height without +// acquiring the deposit lock. +func (d *Deposit) GetConfirmationHeightNoLock() int64 { + return d.ConfirmationHeight +} + // GetRandomDepositID generates a random deposit ID. func GetRandomDepositID() (ID, error) { var id ID diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index dad14d333..cdfb73a9b 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -452,17 +452,14 @@ func (f *FSM) updateDeposit(ctx context.Context, return } - type checkStateFunc func(state fsm.StateType) bool - type setStateFunc func(state fsm.StateType) - checkFunc := checkStateFunc(f.deposit.IsInState) - setFunc := setStateFunc(f.deposit.SetState) - if _, ok := lockedEvents[notification.Event]; ok { - checkFunc = f.deposit.IsInStateNoLock - setFunc = f.deposit.SetStateNoLock + _, alreadyLocked := lockedEvents[notification.Event] + if !alreadyLocked { + f.deposit.Lock() + defer f.deposit.Unlock() } - setFunc(notification.NextState) - if isUpdateSkipped(notification, checkFunc) { + f.deposit.SetStateNoLock(notification.NextState) + if isUpdateSkipped(notification, f.deposit.IsInStateNoLock) { return } diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 2294f55de..ed1ca2617 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -447,8 +447,8 @@ func (m *Manager) GetActiveDepositsInState(stateFilter fsm.StateType) ( } sort.Slice(filteredDeposits, func(i, j int) bool { - return filteredDeposits[i].ConfirmationHeight < - filteredDeposits[j].ConfirmationHeight + return filteredDeposits[i].GetConfirmationHeightNoLock() < + filteredDeposits[j].GetConfirmationHeightNoLock() }) return filteredDeposits, nil @@ -544,7 +544,8 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, for _, deposit := range deposits { if deposit.isInFinalStateNoLock() { return fmt.Errorf("deposit %v is no longer active in "+ - "state %v", deposit.OutPoint, deposit.state) + "state %v", deposit.OutPoint, + deposit.GetStateNoLock()) } } @@ -598,6 +599,9 @@ func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) { // 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() + defer d.Unlock() + return m.cfg.Store.UpdateDeposit(ctx, d) } diff --git a/staticaddr/deposit/sql_store.go b/staticaddr/deposit/sql_store.go index d9f4249fe..03a03975a 100644 --- a/staticaddr/deposit/sql_store.go +++ b/staticaddr/deposit/sql_store.go @@ -47,7 +47,7 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { TxHash: deposit.Hash[:], OutIndex: int32(deposit.Index), Amount: int64(deposit.Value), - ConfirmationHeight: deposit.ConfirmationHeight, + ConfirmationHeight: deposit.GetConfirmationHeight(), TimeoutSweepPkScript: deposit.TimeOutSweepPkScript, } @@ -69,11 +69,15 @@ func (s *SqlStore) CreateDeposit(ctx context.Context, deposit *Deposit) error { } // UpdateDeposit updates the deposit in the database. +// +// Callers that pass a live deposit must hold the deposit lock while calling +// this method. The deposit FSM already does this for state transitions, and +// Manager.UpdateDeposit wraps external callers with the same lock. func (s *SqlStore) UpdateDeposit(ctx context.Context, deposit *Deposit) error { insertUpdateArgs := sqlc.InsertDepositUpdateParams{ DepositID: deposit.ID[:], UpdateTimestamp: s.clock.Now().UTC(), - UpdateState: string(deposit.state), + UpdateState: string(deposit.GetStateNoLock()), } var ( @@ -83,7 +87,7 @@ func (s *SqlStore) UpdateDeposit(ctx context.Context, deposit *Deposit) error { Valid: true, } confirmationHeight = sql.NullInt64{ - Int64: deposit.ConfirmationHeight, + Int64: deposit.GetConfirmationHeightNoLock(), } ) diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go index c605c43ef..44dd12f49 100644 --- a/staticaddr/loopin/autoloop_dp.go +++ b/staticaddr/loopin/autoloop_dp.go @@ -234,9 +234,9 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, continue } + confirmationHeight := candidateDeposit.GetConfirmationHeight() swappable := IsSwappable( - uint32(candidateDeposit.ConfirmationHeight), - blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, csvExpiry, ) if !swappable { continue @@ -246,7 +246,7 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, continue } - residualLife := candidateDeposit.ConfirmationHeight + + residualLife := confirmationHeight + int64(csvExpiry) - int64(blockHeight) eligibleDeposits = append( diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 06ba43d6e..b1074195b 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -861,8 +861,9 @@ func SelectDeposits(targetAmount btcutil.Amount, // Filter out deposits that are too close to expiry to be swapped. var deposits []*deposit.Deposit for _, d := range unfilteredDeposits { + confirmationHeight := d.GetConfirmationHeight() if !IsSwappable( - uint32(d.ConfirmationHeight), blockHeight, csvExpiry, + uint32(confirmationHeight), blockHeight, csvExpiry, ) { log.Debugf("Skipping deposit %s as it expires before "+ @@ -878,9 +879,9 @@ func SelectDeposits(targetAmount btcutil.Amount, // blocks-until-expiry in ascending order. sort.Slice(deposits, func(i, j int) bool { if deposits[i].Value == deposits[j].Value { - iExp := uint32(deposits[i].ConfirmationHeight) + + iExp := uint32(deposits[i].GetConfirmationHeight()) + csvExpiry - blockHeight - jExp := uint32(deposits[j].ConfirmationHeight) + + jExp := uint32(deposits[j].GetConfirmationHeight()) + csvExpiry - blockHeight return iExp < jExp diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 719676237..22acb79ed 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -669,7 +669,7 @@ func (m *Manager) handleWithdrawal(ctx context.Context, d := deposits[0] spentChan, errChan, err := m.cfg.ChainNotifier.RegisterSpendNtfn( ctx, &d.OutPoint, addrParams.PkScript, - int32(d.ConfirmationHeight), + int32(d.GetConfirmationHeight()), ) if err != nil { return fmt.Errorf("unable to register spend ntfn: %w", err) From 5320664a6bd79566b0152b6df0696d209500472e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 15:48:21 +0200 Subject: [PATCH 15/36] staticaddr/loopin: keep htlc timeout sweep resumable A shutdown while publishing or monitoring the HTLC timeout sweep should not transition the loop-in to Failed. Return NoOp on context cancellation in those actions so the persisted state remains a recovery point. Add focused tests for shutdown during publication retry and confirmation monitoring. --- staticaddr/loopin/actions.go | 16 +++++-- staticaddr/loopin/actions_test.go | 69 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 42ac7f1e7..a30a60c32 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -810,10 +810,10 @@ func (f *FSM) SweepHtlcTimeoutAction(ctx context.Context, select { // The context is cancelled when the server is shutting - // down. In that case we give up broadcasting attempts - // and return an error. + // down. Keep the current state so recovery resumes + // broadcasting attempts after restart. case <-ctx.Done(): - return f.HandleError(ctx.Err()) + return fsm.NoOp case <-time.After(htlcTimeoutSweepRetryDelay): } @@ -847,6 +847,10 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context, ) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to register to the htlc timeout "+ "sweep tx: %w", err) @@ -856,6 +860,10 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context, for { select { case err := <-errChan: + if ctx.Err() != nil { + return fsm.NoOp + } + return f.HandleError(err) case conf := <-htlcTimeoutTxidChan: @@ -879,7 +887,7 @@ func (f *FSM) MonitorHtlcTimeoutSweepAction(ctx context.Context, return OnHtlcTimeoutSwept case <-ctx.Done(): - return f.HandleError(ctx.Err()) + return fsm.NoOp } } } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 64e2bcde1..0cc80e1da 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -205,6 +205,75 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { } } +// TestSweepHtlcTimeoutActionNoOpOnShutdown ensures that a shutdown during +// timeout sweep publication keeps the FSM in the same state so it can resume +// after restart. +func TestSweepHtlcTimeoutActionNoOpOnShutdown(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + mockLnd := test.NewMockLnd() + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + LndClient: mockLnd.Client, + WalletKit: mockLnd.WalletKit, + }, + loopIn: &StaticAddressLoopIn{}, + } + + event := f.SweepHtlcTimeoutAction(ctx, nil) + require.Equal(t, fsm.NoOp, event) + require.Nil(t, f.LastActionError) +} + +// TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown ensures that a shutdown +// while waiting for the timeout sweep confirmation keeps the FSM resumable. +func TestMonitorHtlcTimeoutSweepActionNoOpOnShutdown(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + sweepAddr, err := mockLnd.WalletKit.NextAddr(ctx, "", 0, false) + require.NoError(t, err) + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + ChainNotifier: mockLnd.ChainNotifier, + }, + loopIn: &StaticAddressLoopIn{ + HtlcTimeoutSweepAddress: sweepAddr, + InitiationHeight: uint32(mockLnd.Height), + }, + } + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorHtlcTimeoutSweepAction(ctx, nil) + }() + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("timeout sweep conf registration not received: %v", + ctx.Err()) + } + + cancel() + + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + require.Nil(t, f.LastActionError) + + case <-time.After(5 * time.Second): + t.Fatal("timeout sweep monitor did not return") + } +} + // TestInitHtlcActionPreservesRouteHints asserts that static-address loop-in // propagates explicit route hints into the encoded swap invoice sent to the // server. From 5c21511c1e966a461a279f28abbf75c1edd2faa0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 07:49:23 +0200 Subject: [PATCH 16/36] staticaddr/loopin: keep htlc monitor resumable After the client gives the server HTLC signatures, shutdown must not drive the monitor state through the generic error path. That path cancels the invoice and attempts to unlock deposits even though the server can still publish the HTLC. Return NoOp for monitor-state cancellation races and cover shutdown with a regression test that asserts no invoice cancellation or deposit unlock occurs. --- staticaddr/loopin/actions.go | 39 ++++++++++++- staticaddr/loopin/actions_test.go | 91 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index a30a60c32..7b32f4755 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -565,6 +565,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, subscribeCtx, f.loopIn.SwapHash, ) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to subscribe to swap "+ "invoice: %w", err) @@ -592,6 +596,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfChan, htlcErrConfChan, err := registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to monitor htlc tx confirmation: %w", err) @@ -602,15 +610,23 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, registerBlocks := f.cfg.ChainNotifier.RegisterBlockEpochNtfn blockChan, blockChanErr, err := registerBlocks(ctx) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to subscribe to new blocks: %w", err) return f.HandleError(err) } - htlcConfirmed := false - + // 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) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to look up invoice by swap hash: %w", err) @@ -658,6 +674,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.cancelSwapInvoice() } + htlcConfirmed := false for { select { case <-htlcConfChan: @@ -666,6 +683,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed = true case err = <-htlcErrConfChan: + if ctx.Err() != nil { + return fsm.NoOp + } + f.Errorf("htlc tx conf chan error, re-registering: "+ "%v", err) @@ -676,6 +697,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Re-register for htlc confirmation. htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to re-register for "+ "htlc tx confirmation: %w", err) @@ -690,6 +715,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to monitor htlc tx "+ "confirmation: %v", err) @@ -761,6 +790,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return OnSweepHtlcTimeout case err = <-blockChanErr: + if ctx.Err() != nil { + return fsm.NoOp + } + f.Errorf("block subscription error: %v", err) return f.HandleError(err) @@ -784,7 +817,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.Errorf("invoice subscription error: %v", err) case <-ctx.Done(): - return f.HandleError(ctx.Err()) + return fsm.NoOp } } } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 0cc80e1da..bec627f0f 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -205,6 +205,97 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { } } +// TestMonitorInvoiceAndHtlcTxNoOpOnShutdown ensures that a shutdown while the +// client is monitoring an HTLC-signed loop-in keeps the swap resumable instead +// of entering the generic unlock path. +func TestMonitorInvoiceAndHtlcTxNoOpOnShutdown(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{4, 5, 6} + 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, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.Invoices[swapHash] = &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(runCtx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil) + }() + + 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()) + } + + 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.Nil(t, f.LastActionError) + require.Empty(t, depositMgr.transitions) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled on shutdown: %v", hash) + + default: + } +} + // TestSweepHtlcTimeoutActionNoOpOnShutdown ensures that a shutdown during // timeout sweep publication keeps the FSM in the same state so it can resume // after restart. From 31553b08bb48c627bd21f233d80c843c61990be0 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:44:42 +0200 Subject: [PATCH 17/36] staticaddr/deposit: canonicalize multi-deposit locks --- staticaddr/deposit/manager.go | 33 ++++++--- staticaddr/deposit/manager_reconcile_test.go | 70 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index ed1ca2617..85b9e9e1d 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -62,7 +62,8 @@ type ManagerConfig struct { // // Lock order: if both Manager.mu and a Deposit lock are needed, acquire // Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a -// Deposit lock. +// Deposit lock. Multiple deposits must be locked with lockDeposits, which +// canonicalizes lock order by outpoint. type Manager struct { cfg *ManagerConfig @@ -434,8 +435,8 @@ func (m *Manager) GetActiveDepositsInState(stateFilter fsm.StateType) ( deposits = append(deposits, fsm.deposit) } - lockDeposits(deposits) - defer unlockDeposits(deposits) + lockedDeposits := lockDeposits(deposits) + defer unlockDeposits(lockedDeposits) filteredDeposits := make([]*Deposit, 0, len(deposits)) for _, d := range deposits { @@ -479,8 +480,8 @@ func (m *Manager) AllOutpointsActiveDeposits(outpoints []wire.OutPoint, return deposits, true } - lockDeposits(deposits) - defer unlockDeposits(deposits) + lockedDeposits := lockDeposits(deposits) + defer unlockDeposits(lockedDeposits) for _, d := range deposits { if !d.IsInStateNoLock(targetState) { return nil, false @@ -539,8 +540,8 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, return fmt.Errorf("deposits not found in active deposits") } - lockDeposits(deposits) - defer unlockDeposits(deposits) + lockedDeposits := lockDeposits(deposits) + defer unlockDeposits(lockedDeposits) for _, deposit := range deposits { if deposit.isInFinalStateNoLock() { return fmt.Errorf("deposit %v is no longer active in "+ @@ -566,14 +567,26 @@ func (m *Manager) TransitionDeposits(ctx context.Context, deposits []*Deposit, return nil } -func lockDeposits(deposits []*Deposit) { - for _, d := range deposits { +// lockDeposits locks deposits in canonical outpoint order and returns the +// ordered slice that must be passed to unlockDeposits. +func lockDeposits(deposits []*Deposit) []*Deposit { + lockedDeposits := append([]*Deposit(nil), deposits...) + sort.Slice(lockedDeposits, func(i, j int) bool { + return lockedDeposits[i].OutPoint.String() < + lockedDeposits[j].OutPoint.String() + }) + + for _, d := range lockedDeposits { d.Lock() } + + return lockedDeposits } +// unlockDeposits unlocks deposits in reverse lock order. func unlockDeposits(deposits []*Deposit) { - for _, d := range deposits { + for i := len(deposits) - 1; i >= 0; i-- { + d := deposits[i] d.Unlock() } } diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index eb68acb8e..972feefdc 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -2,6 +2,7 @@ package deposit import ( "testing" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -56,3 +57,72 @@ func TestTransitionDepositsRejectsDuplicateOutpoints(t *testing.T) { require.ErrorContains(t, err, "duplicate deposit outpoint") require.Equal(t, Deposited, deposit.GetState()) } + +// TestLockDepositsCanonicalizesOutpoints verifies that lockDeposits takes a +// canonical copy of the caller's slice so overlapping multi-deposit operations +// cannot lock deposits in conflicting request orders. +func TestLockDepositsCanonicalizesOutpoints(t *testing.T) { + depositA := &Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + } + depositB := &Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 0, + }, + } + + deposits := []*Deposit{depositB, depositA} + lockedDeposits := lockDeposits(deposits) + defer unlockDeposits(lockedDeposits) + + require.Equal(t, []*Deposit{depositA, depositB}, lockedDeposits) + require.Equal(t, []*Deposit{depositB, depositA}, deposits) +} + +// TestLockDepositsAllowsReversedConcurrentRequests exercises the reviewer +// case where overlapping callers request the same deposits in opposite orders. +func TestLockDepositsAllowsReversedConcurrentRequests(t *testing.T) { + depositA := &Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 0, + }, + } + depositB := &Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{4}, + Index: 0, + }, + } + + start := make(chan struct{}) + done := make(chan struct{}, 2) + lockAndUnlock := func(deposits []*Deposit) { + <-start + + for range 100 { + lockedDeposits := lockDeposits(deposits) + unlockDeposits(lockedDeposits) + } + + done <- struct{}{} + } + + go lockAndUnlock([]*Deposit{depositA, depositB}) + go lockAndUnlock([]*Deposit{depositB, depositA}) + + close(start) + + for range 2 { + select { + case <-done: + + case <-time.After(time.Second): + t.Fatal("reversed deposit lock requests deadlocked") + } + } +} From 793799583fe52b8f98aef3324cf22634160a228a Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:46:02 +0200 Subject: [PATCH 18/36] staticaddr/loopin: recover deposits by current outpoint Recovered loop-ins carry two outpoint views. DepositOutpoints is the immutable swap input snapshot sent to the server and used to validate sweep requests. Deposits comes from the store's swap_hash/deposit-id join and reflects the current deposit rows. The active-deposit lookup takes a detour through the reconstructed deposit rows before asking the deposit manager for active deposits. That keeps recovery from depending on the historical input snapshot. A future replacement path can RBF a deposit from its original funding outpoint to a replacement outpoint while the swap still needs to retain the original input list. Looking up active deposits by DepositOutpoints would then fail recovery even though the store still maps the correct deposit IDs to the swap hash. Keep list responses on the store reconstruction too, so they do not re-resolve deposits through historical outpoints. --- staticaddr/loopin/manager.go | 54 +++++++++++--------------- staticaddr/loopin/manager_test.go | 64 ++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 33 deletions(-) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index b1074195b..9914c2d5b 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -533,17 +533,15 @@ func (m *Manager) recoverLoopIns(ctx context.Context) error { for _, loopIn := range pendingLoopIns { log.Debugf("Recovering loopIn %x", loopIn.SwapHash[:]) - // Retrieve all deposits regardless of deposit state. If any of - // the deposits is not active in the in-mem map of the deposits - // manager we log it, but continue to recover the loop-in. - var allActive bool - loopIn.Deposits, allActive = - m.cfg.DepositManager.AllStringOutpointsActiveDeposits( - loopIn.DepositOutpoints, fsm.EmptyState, - ) - + // Retrieve all deposits regardless of deposit state. If all + // deposits are active in the in-mem map of the deposits manager, + // use those active instances. Otherwise, keep the store's + // swap_hash/deposit-id reconstruction and continue recovery. + activeDeposits, allActive := m.activeDepositsForLoopIn(loopIn) if !allActive { log.Errorf("one or more deposits are not active") + } else { + loopIn.Deposits = activeDeposits } loopIn.AddressParams, err = @@ -818,35 +816,29 @@ func (m *Manager) startLoopInFsm(ctx context.Context, func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn, error) { - swaps, err := m.cfg.Store.GetStaticAddressLoopInSwapsByStates( + return m.cfg.Store.GetStaticAddressLoopInSwapsByStates( ctx, AllStates, ) - if err != nil { - return nil, err - } - - allDeposits, err := m.cfg.DepositManager.GetAllDeposits(ctx) - if err != nil { - return nil, err - } +} - var depositLookup = make(map[string]*deposit.Deposit) - for i, d := range allDeposits { - depositLookup[d.OutPoint.String()] = allDeposits[i] - } +// activeDepositsForLoopIn returns the active deposit instances for a loop-in +// using the current deposit outpoints reconstructed by the store. The stored +// deposit outpoint snapshots remain the original swap inputs and are not the +// source of truth for current deposit rows. +func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( + []*deposit.Deposit, bool) { - for i, s := range swaps { - var deposits []*deposit.Deposit - for _, outpoint := range s.DepositOutpoints { - if d, ok := depositLookup[outpoint]; ok { - deposits = append(deposits, d) - } + outpoints := loopIn.DepositOutpoints + if len(loopIn.Deposits) > 0 { + outpoints = make([]string, 0, len(loopIn.Deposits)) + for _, d := range loopIn.Deposits { + outpoints = append(outpoints, d.OutPoint.String()) } - - swaps[i].Deposits = deposits } - return swaps, nil + return m.cfg.DepositManager.AllStringOutpointsActiveDeposits( + outpoints, fsm.EmptyState, + ) } // SelectDeposits sorts the deposits by amount in descending order, then by diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 85178a05b..ed31df8ab 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -298,6 +298,65 @@ func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { require.ErrorContains(t, err, depOutpoint) } +// TestActiveDepositsForLoopInUsesCurrentDepositOutpoints verifies that +// recovery checks the current deposit outpoints reconstructed by the store +// rather than the original outpoint snapshot persisted on the swap. +func TestActiveDepositsForLoopInUsesCurrentDepositOutpoints(t *testing.T) { + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0xaa}, + Index: 0, + } + currentDeposit := makeDeposit(0xbb, 1, 10_000, 42) + + manager := &Manager{ + cfg: &Config{ + DepositManager: &mockDepositManager{ + byOutpoint: map[string]*deposit.Deposit{ + currentDeposit.OutPoint.String(): currentDeposit, + }, + }, + }, + } + + deposits, allActive := manager.activeDepositsForLoopIn( + &StaticAddressLoopIn{ + DepositOutpoints: []string{oldOutpoint.String()}, + Deposits: []*deposit.Deposit{currentDeposit}, + }, + ) + require.True(t, allActive) + require.Equal(t, []*deposit.Deposit{currentDeposit}, deposits) +} + +// TestGetAllSwapsPreservesStoreDeposits verifies that list responses keep the +// store's swap_hash/deposit-id reconstruction even when DepositOutpoints is an +// original input snapshot and the deposit's current outpoint has changed. +func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { + oldOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{0xcc}, + Index: 0, + } + currentDeposit := makeDeposit(0xdd, 1, 10_000, 42) + swap := &StaticAddressLoopIn{ + DepositOutpoints: []string{oldOutpoint.String()}, + Deposits: []*deposit.Deposit{currentDeposit}, + } + + manager := &Manager{ + cfg: &Config{ + Store: &mockStore{ + swaps: []*StaticAddressLoopIn{swap}, + }, + }, + } + + swaps, err := manager.GetAllSwaps(t.Context()) + require.NoError(t, err) + require.Len(t, swaps, 1) + require.Equal(t, []string{oldOutpoint.String()}, swaps[0].DepositOutpoints) + require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { // activeDeposits is the set returned by GetActiveDepositsInState. @@ -316,7 +375,7 @@ func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string, state fsm.StateType) ([]*deposit.Deposit, bool) { - if state != deposit.Deposited { + if state != deposit.Deposited && state != fsm.EmptyState { return nil, false } @@ -408,6 +467,7 @@ func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, // mockStore implements StaticAddressLoopInStore for tests. type mockStore struct { + swaps []*StaticAddressLoopIn loopIns map[lntypes.Hash]*StaticAddressLoopIn mapIDs map[lntypes.Hash][]deposit.ID } @@ -427,7 +487,7 @@ func (s *mockStore) UpdateLoopIn(_ context.Context, func (s *mockStore) GetStaticAddressLoopInSwapsByStates(_ context.Context, _ []fsm.StateType) ([]*StaticAddressLoopIn, error) { - return nil, nil + return s.swaps, nil } func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { return false, nil From 12d11e7b39966c3a7557aff5568873ed6fbb4c5d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:47:06 +0200 Subject: [PATCH 19/36] staticaddr/loopin: use payment timeout duration helper --- staticaddr/loopin/loopin.go | 4 ++-- staticaddr/loopin/loopin_test.go | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 6c1860bdb..9b0fc03bd 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -470,9 +470,9 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { // initiation time of the swap. If more than the swap's configured payment // timeout has passed, the remaining time will be negative. func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 { - elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds() + deadline := l.InitiationTime.Add(l.PaymentTimeoutDuration()) - return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation) + return int64(time.Until(deadline).Seconds()) } // PaymentTimeoutDuration returns the configured payment timeout duration, diff --git a/staticaddr/loopin/loopin_test.go b/staticaddr/loopin/loopin_test.go index d4be020fe..daddfd674 100644 --- a/staticaddr/loopin/loopin_test.go +++ b/staticaddr/loopin/loopin_test.go @@ -147,6 +147,21 @@ func TestCreateHtlcSweepTxSweepValue(t *testing.T) { "the HTLC output") } +func TestPaymentTimeoutDuration(t *testing.T) { + t.Parallel() + + require.Equal( + t, time.Duration(DefaultPaymentTimeoutSeconds)*time.Second, + (&StaticAddressLoopIn{}).PaymentTimeoutDuration(), + ) + require.Equal( + t, 42*time.Second, + (&StaticAddressLoopIn{ + PaymentTimeoutSeconds: 42, + }).PaymentTimeoutDuration(), + ) +} + // newStaticAddress creates a StaticAddress for testing. func newStaticAddress(clientKey, serverKey *btcec.PublicKey, csvExpiry int64) (*script.StaticAddress, error) { From d187d07eee2f41ad8bf0c24343570a8fe433f54e Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:48:13 +0200 Subject: [PATCH 20/36] staticaddr/loopin: check deposits before htlc signing Before we send HTLC signatures to the server, the server cannot publish the HTLC transaction. After those signatures are handed over, the server can publish an HTLC that spends the selected deposits even if it never pays the swap invoice. Defend against stale local deposit state by checking the wallet's current txout view immediately before signing. A deposit can have been spent by a known withdrawal, channel open, timeout sweep, replacement, or another wallet transaction while the loop-in FSM is recovering or while earlier state still marked it as selected. Failing before signing leaves the server without spend authority over an unavailable input. Include mempool spends in the check so wallet-known unconfirmed spends are treated as unavailable too. --- loopd/daemon.go | 1 + staticaddr/loopin/actions.go | 28 +++++++++++++++++ staticaddr/loopin/actions_test.go | 50 +++++++++++++++++++++++++++++++ staticaddr/loopin/manager.go | 4 +++ 4 files changed, 83 insertions(+) diff --git a/loopd/daemon.go b/loopd/daemon.go index 91267811e..dd29d1a5d 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -692,6 +692,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { Server: staticAddressClient, QuoteGetter: swapClient.Server, LndClient: d.lnd.Client, + TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client), InvoicesClient: d.lnd.Invoices, NodePubkey: d.lnd.NodePubkey, AddressManager: staticAddressManager, diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 7b32f4755..9ae462049 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -413,6 +413,11 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, return f.HandleError(err) } + err = f.checkDepositsAvailable(ctx) + if err != nil { + return f.HandleError(err) + } + // Create a musig2 session for each deposit and different htlc tx fee // rates. createSession := staticutil.CreateMusig2Sessions @@ -526,6 +531,29 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, return OnHtlcTxSigned } +func (f *FSM) checkDepositsAvailable(ctx context.Context) error { + if f.cfg.TxOutChecker == nil { + return nil + } + + for _, d := range f.loopIn.Deposits { + txOut, err := f.cfg.TxOutChecker.GetTxOut( + ctx, d.OutPoint, true, + ) + if err != nil { + return fmt.Errorf("unable to check deposit %v: %w", + d.OutPoint, err) + } + + if txOut == nil { + return fmt.Errorf("deposit %v is no longer available", + d.OutPoint) + } + } + + return nil +} + // cleanUpSessions releases allocated memory of the musig2 sessions. func (f *FSM) cleanUpSessions(ctx context.Context, sessions []*input.MuSig2SessionInfo) { diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index bec627f0f..857c7c49a 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -428,6 +428,41 @@ func TestInitHtlcActionPreservesRouteHints(t *testing.T) { test.RequireRouteHintsEqual(t, loopIn.RouteHints, routeHints) } +func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { + dep := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x77}, + Index: 2, + }, + Value: 200_000, + } + checker := &recordingTxOutChecker{} + + f := &FSM{ + StateMachine: &fsm.StateMachine{}, + cfg: &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + TxOutChecker: checker, + }, + loopIn: &StaticAddressLoopIn{ + Deposits: []*deposit.Deposit{dep}, + }, + } + + event := f.SignHtlcTxAction(t.Context(), nil) + require.Equal(t, fsm.OnError, event) + require.ErrorContains( + t, f.LastActionError, "deposit "+ + dep.OutPoint.String()+" is no longer available", + ) + require.Equal(t, []wire.OutPoint{dep.OutPoint}, checker.outpoints) + require.Equal(t, []bool{true}, checker.includeMempool) +} + // mockStaticAddressServer captures static-address loop-in requests in tests. type mockStaticAddressServer struct { swapserverrpc.StaticAddressServerClient @@ -778,6 +813,21 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, return r.err } +type recordingTxOutChecker struct { + outpoints []wire.OutPoint + includeMempool []bool +} + +// GetTxOut records the request and reports that the outpoint is unavailable. +func (r *recordingTxOutChecker) GetTxOut(_ context.Context, + outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { + + r.outpoints = append(r.outpoints, outpoint) + r.includeMempool = append(r.includeMempool, includeMempool) + + return nil, nil +} + // initHtlcTestServer lets InitHtlcAction tests inject a deterministic server // response without standing up the full gRPC client. type initHtlcTestServer struct { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 9914c2d5b..519695780 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -56,6 +56,10 @@ type Config struct { // LndClient is used to add invoices and select hop hints. LndClient lndclient.LightningClient + // TxOutChecker checks that selected deposits are still available before + // the client gives the server HTLC signatures. + TxOutChecker TxOutChecker + // InvoicesClient is used to subscribe to invoice settlements and // cancel invoices. InvoicesClient lndclient.InvoicesClient From 62945ad2965be54ebc25d8fff5d6f169cf69b49f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:49:01 +0200 Subject: [PATCH 21/36] staticaddr/loopin: scan wallet txouts once The txout checker needs a conservative answer when lnd's wallet history contains both the original deposit transaction and a later wallet-known spend of that outpoint. Scan the wallet transaction list once, remember a matching live txout, and keep scanning for matching previous outpoints. A known spend must win over the candidate funding output regardless of transaction ordering in the wallet response. This keeps the availability check cheap and preserves the rule that a deposit is unavailable if any wallet-known confirmed or mempool transaction already spends it. --- staticaddr/loopin/txout_checker.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/staticaddr/loopin/txout_checker.go b/staticaddr/loopin/txout_checker.go index 61463cc88..8f218f61d 100644 --- a/staticaddr/loopin/txout_checker.go +++ b/staticaddr/loopin/txout_checker.go @@ -40,15 +40,14 @@ func (c *lndTxOutChecker) GetTxOut(ctx context.Context, } outpointStr := outpoint.String() + var txOut *wire.TxOut for _, tx := range txs { for _, prevOutpoint := range tx.PreviousOutpoints { if prevOutpoint.GetOutpoint() == outpointStr { return nil, nil } } - } - for _, tx := range txs { if tx.Tx == nil { continue } @@ -65,8 +64,8 @@ func (c *lndTxOutChecker) GetTxOut(ctx context.Context, return nil, nil } - return tx.Tx.TxOut[outpoint.Index], nil + txOut = tx.Tx.TxOut[outpoint.Index] } - return nil, nil + return txOut, nil } From 9e0cb3018a7b82e39c05b75117ca2592bb8ecb31 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 10:55:13 +0200 Subject: [PATCH 22/36] staticaddr/loopin: update payment timeout comment --- staticaddr/loopin/actions.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 9ae462049..9cce98762 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -669,8 +669,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, 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 set the timeout to 0 to cancel the - // invoice and unlock the deposits immediately. + // 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 From b207508b98a010f32f946c9b72b66cdee4be9129 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 6 Jul 2026 13:58:28 +0200 Subject: [PATCH 23/36] staticaddr/openchannel: fix nolint directive --- staticaddr/openchannel/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 92d9276c0..dab1aef62 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -618,7 +618,7 @@ func (m *Manager) openChannelPsbt(ctx context.Context, "address: %w", err) } - //nolint:ll + //nolint:lll signedTx, unsignedPsbt, err := m.cfg.WithdrawalManager.CreateFinalizedWithdrawalTx( ctx, deposits, channelFundingAddress, feeRate, fundingAmount, req.CommitmentType, From 9ccfb2d777efe34db8850567b87dfbd06db1ec1d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 6 Jul 2026 13:58:45 +0200 Subject: [PATCH 24/36] build: increase lint timeout --- .golangci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 5b870ccbe..329dcde25 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,7 @@ run: go: "1.26" # timeout for analysis - timeout: 4m + timeout: 6m linters: default: all From 9c5ca366c650128c0323272ae4bd6318f221d023 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 12:09:26 +0200 Subject: [PATCH 25/36] 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. Replay the startup block to recovered deposit FSMs so expiry handling can run immediately after restart. Derive confirmation heights from a stable wallet view because lnd reports confirmation counts. --- staticaddr/deposit/deposit.go | 7 +- staticaddr/deposit/deposit_test.go | 17 ++ staticaddr/deposit/fsm.go | 8 +- staticaddr/deposit/manager.go | 156 ++++++++--- staticaddr/deposit/manager_height_test.go | 37 +++ staticaddr/deposit/manager_reconcile_test.go | 265 +++++++++++++++++++ staticaddr/deposit/manager_test.go | 59 +++++ 7 files changed, 510 insertions(+), 39 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 3a1afc4a9..bcb9c5af4 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 @@ -89,6 +90,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 cdfb73a9b..70a4644e3 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 85b9e9e1d..659f33d46 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" @@ -18,9 +19,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 @@ -87,6 +87,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. @@ -108,6 +111,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 { @@ -123,6 +139,13 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { log.Errorf("unable to reconcile deposits: %v", err) } + // The startup height was consumed before recovered deposit FSMs existed. + // Replay it so already-expired recovered deposits can act immediately. + err = m.notifyActiveDeposits(ctx, startupHeight) + if err != nil { + return err + } + // Start the deposit notifier. m.pollDeposits(ctx) @@ -133,7 +156,14 @@ 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) + } + + err = m.notifyActiveDeposits(ctx, uint32(height)) if err != nil { return err } @@ -227,8 +257,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...") @@ -262,12 +294,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...") @@ -275,7 +314,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) @@ -295,9 +334,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 } @@ -325,7 +366,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, state: Deposited, OutPoint: utxo.OutPoint, Value: utxo.Value, - ConfirmationHeight: int64(blockHeight), + ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, } @@ -341,37 +382,84 @@ 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 + + var confirmationHeight int64 + switch { + case utxo.Confirmations <= 0: + confirmationHeight = 0 + + case previousConfirmationHeight > 0: + deposit.Unlock() + continue + + default: + var err error + 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..aec8121a5 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -1,14 +1,195 @@ 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) +} + // TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a // duplicated selection is rejected before the manager tries to lock the same // deposit twice. @@ -126,3 +307,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..718a644cc 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -237,6 +237,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: @@ -307,6 +311,61 @@ 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") + } +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { From b3ddee8cef450f693a1a7aa9c05a3a7b53c9cd8d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:50:00 +0200 Subject: [PATCH 26/36] 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. Keep withdrawal and channel-open flows on confirmed inputs by rejecting unconfirmed selected deposits in those paths. --- loopd/swapclient_server.go | 148 +++++++++++------ loopd/swapclient_server_deposit_test.go | 86 ++++++++++ loopd/swapclient_server_staticaddr_test.go | 135 ++++++++++++++++ loopd/swapclient_server_test.go | 179 ++++++++++++++++----- staticaddr/openchannel/manager.go | 28 ++++ staticaddr/openchannel/manager_test.go | 108 ++++++++++++- staticaddr/withdraw/manager.go | 9 ++ 7 files changed, 590 insertions(+), 103 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..44a0af0fb 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. @@ -1791,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: @@ -1815,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. @@ -1988,9 +2022,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 +2117,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 +2276,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..3d4ce065a --- /dev/null +++ b/loopd/swapclient_server_deposit_test.go @@ -0,0 +1,86 @@ +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 +// 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) + } + }) +} + +// 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/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) }) } diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index dab1aef62..83fe64665 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -308,6 +308,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 { @@ -322,6 +326,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.ConfirmationHeight <= 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. @@ -397,6 +409,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.ConfirmationHeight <= 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..16088da85 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.ConfirmationHeight <= 0 { + return "", "", fmt.Errorf("can't withdraw, " + + "unconfirmed deposits can't be withdrawn") + } + } + var ( withdrawalAddress btcutil.Address err error From 2c07368c1e5314c740f87dd28433ebe9d6dddc91 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 6 Jul 2026 11:59:12 +0200 Subject: [PATCH 27/36] 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 +++++++++++++ staticaddr/openchannel/manager.go | 4 +- staticaddr/withdraw/manager.go | 2 +- 4 files changed, 74 insertions(+), 22 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. diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 83fe64665..1f92d0749 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -329,7 +329,7 @@ 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.ConfirmationHeight <= 0 { + if d.GetConfirmationHeight() <= 0 { return nil, ErrOpeningChannelUnavailableDeposits } } @@ -415,7 +415,7 @@ func (m *Manager) OpenChannel(ctx context.Context, func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit { confirmed := make([]*deposit.Deposit, 0, len(deposits)) for _, d := range deposits { - if d.ConfirmationHeight <= 0 { + if d.GetConfirmationHeight() <= 0 { continue } diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index 16088da85..f7c8284f9 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -384,7 +384,7 @@ 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.ConfirmationHeight <= 0 { + if d.GetConfirmationHeight() <= 0 { return "", "", fmt.Errorf("can't withdraw, " + "unconfirmed deposits can't be withdrawn") } From dd5b9ceab205503b58ab4386ed07dfd7ad3115cf Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 6 Jul 2026 11:59:22 +0200 Subject: [PATCH 28/36] 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 6372fcf49c97dc9057d6d42400d98208462d042b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:52:51 +0200 Subject: [PATCH 29/36] staticaddr: sync active deposits with wallet Treat lnd wallet view as the source of spendable static-address outputs while keeping historical deposit records in the DB. Reconcile active FSMs against the current wallet view and reactivate known deposits when their outpoints are visible again. Refresh deposits before selection, withdrawal, loop-in, and channel-open paths, and filter list and summary responses through the live active set so stale Deposited records are not exposed as available funds. --- loopd/swapclient_server.go | 30 +- loopd/swapclient_server_staticaddr_test.go | 103 +++++++ staticaddr/deposit/manager.go | 132 +++++++- staticaddr/deposit/manager_reconcile_test.go | 304 ++++++++++++++++++- 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 +- 15 files changed, 625 insertions(+), 23 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 44a0af0fb..d89dbbce2 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( @@ -1010,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) @@ -1798,6 +1812,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, ) @@ -1864,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 } @@ -2101,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 3d229a608..bb4cc01ca 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, @@ -106,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) { diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 659f33d46..a1d0581d4 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -283,6 +283,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 @@ -307,6 +316,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...") @@ -462,6 +476,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 { @@ -693,11 +793,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 aec8121a5..2e134c26b 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 @@ -190,6 +201,102 @@ func TestUpdateDepositConfirmationsResetsReorgedDeposit(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. @@ -308,6 +415,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. diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 857c7c49a..c8a26ceed 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -751,6 +751,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 6197d733b..ec46b9825 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 1f92d0749..f1a416487 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -268,11 +268,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. @@ -291,6 +286,12 @@ func (m *Manager) OpenChannel(ctx context.Context, "request", duplicate) } + 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, @@ -299,6 +300,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 042f0c22f50461608905985aa9c163e19dd58616 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 13:21:19 +0200 Subject: [PATCH 30/36] 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. Wire the lnd-backed checker through loopd and make invoice-monitoring handle closed subscription channels without spinning. --- staticaddr/loopin/actions.go | 48 +++++++++- staticaddr/loopin/actions_test.go | 148 ++++++++++++++++++++++++++++-- test/lightning_client_mock.go | 4 +- test/lnd_services_mock.go | 9 ++ 4 files changed, 198 insertions(+), 11 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 9cce98762..5786d9074 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/chain" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" @@ -343,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 } @@ -388,6 +389,39 @@ 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 + } + + const includeMempool = true + for _, outpointStr := range f.loopIn.DepositOutpoints { + outpoint, err := wire.NewOutPointFromString(outpointStr) + if err != nil { + return false, fmt.Errorf("invalid deposit outpoint %q: %w", + outpointStr, err) + } + + txOut, err := f.cfg.TxOutChecker.GetTxOut( + ctx, *outpoint, includeMempool, + ) + if err != nil { + return false, fmt.Errorf("unable to get txout %v: %w", + outpoint, err) + } + + if txOut == 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. @@ -396,6 +430,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 c8a26ceed..9c8838375 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -131,10 +131,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{ @@ -436,7 +436,7 @@ func TestSignHtlcTxActionChecksDepositAvailability(t *testing.T) { }, Value: 200_000, } - checker := &recordingTxOutChecker{} + checker := &testTxOutChecker{} f := &FSM{ StateMachine: &fsm.StateMachine{}, @@ -542,6 +542,133 @@ 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 := &testTxOutChecker{ + txOut: &wire.TxOut{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) + require.Equal(t, []bool{true}, txOutChecker.includeMempool) +} + +// TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a +// pending loop-in is canceled before HTLC signing if GetTxOut with mempool +// awareness 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 := &testTxOutChecker{} + 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) + require.Equal(t, []bool{true}, txOutChecker.includeMempool) +} + +// TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup +// failures are treated as errors, but do not cancel the invoice. The invoice is +// only canceled when GetTxOut explicitly returns nil for 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 := &testTxOutChecker{ + 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) { @@ -818,19 +945,22 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, return r.err } -type recordingTxOutChecker struct { +type testTxOutChecker struct { + txOut *wire.TxOut + err error + outpoints []wire.OutPoint includeMempool []bool } -// GetTxOut records the request and reports that the outpoint is unavailable. -func (r *recordingTxOutChecker) GetTxOut(_ context.Context, +// GetTxOut records lookup parameters and returns the configured result. +func (t *testTxOutChecker) GetTxOut(_ context.Context, outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { - r.outpoints = append(r.outpoints, outpoint) - r.includeMempool = append(r.includeMempool, includeMempool) + t.outpoints = append(t.outpoints, outpoint) + t.includeMempool = append(t.includeMempool, includeMempool) - return nil, nil + return t.txOut, t.err } // initHtlcTestServer lets InitHtlcAction tests inject a deterministic server 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 be1e0043b4a5a0f5c7be2b5f141ff7d7f37df77d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:53:17 +0200 Subject: [PATCH 31/36] 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. --- notifications/manager.go | 185 +++++ notifications/manager_test.go | 254 +++++++ staticaddr/loopin/actions.go | 249 ++++++- staticaddr/loopin/actions_test.go | 1053 ++++++++++++++++++++++++++++- staticaddr/loopin/interface.go | 14 + 5 files changed, 1725 insertions(+), 30 deletions(-) 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. diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 5786d9074..685d597e8 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,114 @@ 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 + } + + 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) ( @@ -693,6 +802,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) @@ -707,30 +837,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() } + }() - deadlineChan = time.NewTimer(timeout).C - } else { + startPaymentDeadline := func(reason string) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return + } + + 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() @@ -800,6 +934,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 @@ -812,7 +948,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 9c8838375..ffaa7f50f 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -542,6 +542,980 @@ 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 := &testTxOutChecker{} + 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) + require.Empty(t, txOutChecker.includeMempool) +} + +// 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: &testTxOutChecker{}, + } + + 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") + } +} + +// 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) { @@ -876,7 +1850,11 @@ 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 +} // EnsureDepositsFresh implements DepositManager with a no-op. func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error { @@ -905,10 +1883,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. @@ -927,8 +1909,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. @@ -936,15 +1922,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 testTxOutChecker struct { txOut *wire.TxOut err error diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index ec46b9825..f2637885a 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -125,4 +125,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 d8393a3ea471349a16a94e07794112080a57affe Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:54:30 +0200 Subject: [PATCH 32/36] staticaddr/loopin: persist risk decisions Store server confirmation-risk decisions with static loop-in swaps and recover accepted payment-deadline timers after restart. Wire notification persistence through loopd so recovered swaps do not lose pending risk state. Deduplicate notification fanout cache entries by swap hash. --- loopd/daemon.go | 26 +- loopd/swapclient_server_test.go | 8 + ...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 | 9 +- loopdb/sqlc/static_address_loopin.sql.go | 152 +++-- notifications/manager.go | 292 +++++---- notifications/manager_test.go | 184 +++++- staticaddr/loopin/actions.go | 255 ++++++-- staticaddr/loopin/actions_test.go | 602 +++++++++++++++++- staticaddr/loopin/interface.go | 5 + staticaddr/loopin/loopin.go | 26 + staticaddr/loopin/manager_test.go | 7 + staticaddr/loopin/sql_store.go | 52 ++ staticaddr/loopin/sql_store_test.go | 25 + 17 files changed, 1408 insertions(+), 274 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/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, 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/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..21de5bbad 100644 --- a/loopdb/sqlc/queries/static_address_loopin.sql +++ b/loopdb/sqlc/queries/static_address_loopin.sql @@ -33,6 +33,14 @@ SET WHERE swap_hash = $1; +-- name: RecordStaticAddressRiskDecision :exec +UPDATE static_address_swaps +SET + confirmation_risk_decision = $2, + confirmation_risk_decision_time = $3 +WHERE + swap_hash = $1; + -- name: InsertStaticAddressMetaUpdate :exec INSERT INTO static_address_swap_updates ( swap_hash, @@ -150,4 +158,3 @@ WHERE - diff --git a/loopdb/sqlc/static_address_loopin.sql.go b/loopdb/sqlc/static_address_loopin.sql.go index 87949cb3e..3e440c499 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,26 @@ 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 = $3 +WHERE + swap_hash = $1 +` + +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/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{ diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 685d597e8..30209066b 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -851,12 +851,44 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } }() - startPaymentDeadline := func(reason string) { + 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, 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) @@ -864,6 +896,40 @@ 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 + } + + 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. @@ -880,6 +946,110 @@ 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 + } + + 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 @@ -889,6 +1059,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 { @@ -961,7 +1136,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 { @@ -976,47 +1160,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 @@ -1046,25 +1195,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 ffaa7f50f..91d3f1949 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -122,7 +122,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(), @@ -365,6 +365,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. @@ -670,9 +761,238 @@ func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(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() @@ -691,7 +1011,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, @@ -719,6 +1039,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{ @@ -733,6 +1062,7 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { LndClient: mockLnd.Client, ChainParams: mockLnd.ChainParams, NotificationManager: notificationMgr, + Store: store, } f, err := NewFSM(ctx, loopIn, cfg, false) @@ -757,6 +1087,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) @@ -764,7 +1107,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") } @@ -1036,6 +1573,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) @@ -1082,7 +1627,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(), @@ -1909,12 +2454,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. @@ -1936,9 +2482,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 f2637885a..f9fb01bfb 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/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/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) { diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index e90d1bdfa..0dca396c2 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 { @@ -583,6 +628,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, ), @@ -590,6 +638,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 f0c109684..602d66d0a 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -349,6 +349,31 @@ 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)) + + err = swapStore.RecordStaticAddressRiskDecision( + ctx, lntypes.Hash{0x9, 0x9, 0x9}, + ConfirmationRiskDecisionRejected, + ) + require.ErrorIs(t, err, ErrLoopInNotFound) require.Len(t, swap.Deposits, 2) From f76a87c4c4fbceb75233443c3e0c3e8f0ff721fb Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:55:03 +0200 Subject: [PATCH 33/36] 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 bde6664cd37a7c08c7e1475314e1ae5f55583d06 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:56:10 +0200 Subject: [PATCH 34/36] 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 d5672719c41e2a4efa0a0d0edbac00360ff729f9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 26 Jun 2026 12:56:23 +0200 Subject: [PATCH 35/36] build: increase lint timeout Allow golangci-lint more time for the larger static-address test suite. The dynamic-confirmation stack adds several focused tests, and the default timeout is tight on slower CI workers. --- .golangci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 329dcde25..8b5b8bed5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,7 @@ run: go: "1.26" # timeout for analysis - timeout: 6m + timeout: 8m linters: default: all From be5550917920c716df56eb1a2ac29a6fc4f9982c Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 5 May 2026 10:24:29 +0200 Subject: [PATCH 36/36] recover L402 and static address --- README.md | 22 + cmd/loop/main.go | 3 +- cmd/loop/recover.go | 50 + cmd/loop/staticaddr.go | 41 +- .../static-loop-in/01_loop-static-new.json | 25 +- .../static-loop-in/04_loop-static.json | 2 +- docs/loop.1 | 12 +- docs/loop.md | 23 +- go.mod | 2 +- loopd/daemon.go | 47 +- loopd/swapclient_server.go | 39 + looprpc/client.pb.go | 732 ++++-- looprpc/client.pb.gw.go | 77 + looprpc/client.proto | 48 + looprpc/client.swagger.json | 72 + looprpc/client.yaml | 3 + looprpc/client_grpc.pb.go | 42 + looprpc/perms.go | 7 + looprpc/swapclient.pb.json.go | 25 + recovery/README.md | 336 +++ recovery/service.go | 1264 +++++++++ recovery/service_test.go | 2331 +++++++++++++++++ staticaddr/address/interface.go | 10 +- staticaddr/address/manager.go | 217 +- staticaddr/address/manager_test.go | 180 ++ staticaddr/deposit/manager.go | 45 +- staticaddr/deposit/manager_reconcile_test.go | 24 +- staticaddr/deposit/manager_test.go | 68 +- staticaddr/loopin/actions_test.go | 3 + staticaddr/loopin/sql_store_test.go | 13 +- swap/keychain.go | 13 +- swap/keychain_test.go | 24 + test/signer_mock.go | 19 +- test/signer_mock_test.go | 41 + test/walletkit_mock.go | 19 + 35 files changed, 5503 insertions(+), 376 deletions(-) create mode 100644 cmd/loop/recover.go create mode 100644 recovery/README.md create mode 100644 recovery/service.go create mode 100644 recovery/service_test.go create mode 100644 swap/keychain_test.go create mode 100644 test/signer_mock_test.go diff --git a/README.md b/README.md index 0bba28ef8..9c38efeea 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,28 @@ To execute a Loop In: loop in ``` +### Static Address Recovery +Loop now keeps at most one encrypted immutable recovery backup per paid L402 +generation in the active network data directory. A backup is written only after +Loop has both the paid `l402.token` and the concrete static-address parameters +for that generation. + +The backup is encrypted with a key derived by the backing `lnd` wallet. It is +therefore only useful with that same `lnd` instance, or with an `lnd` restored +from the same seed/key material. The Loop backup file alone is not enough to +recover static-address access. + +Existing static-address users get this backup backfilled on the next startup +with the upgraded client. A fresh install that has no local L402 or +static-address state first checks for an existing recovery backup in the active +network directory. If none is restored, startup materializes the initial +paid-L402/static-address generation and writes its backup. + +The follow-up multi-address work is expected to keep this one-backup-per-L402 +model and use the deterministic receive/change key-family metadata already +stored in the backup. See [recovery/README.md](./recovery/README.md) for the +full recovery model and the planned multi-address outlook. + ### More info - [Loop FAQs](./docs/faqs.md) diff --git a/cmd/loop/main.go b/cmd/loop/main.go index 294095ca0..83235185a 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -97,7 +97,8 @@ var ( monitorCommand, quoteCommand, listAuthCommand, fetchL402Command, listSwapsCommand, swapInfoCommand, getLiquidityParamsCommand, setLiquidityRuleCommand, suggestSwapCommand, setParamsCommand, - getInfoCommand, abandonSwapCommand, reservationsCommands, + getInfoCommand, abandonSwapCommand, recoverCommand, + reservationsCommands, instantOutCommand, listInstantOutsCommand, stopCommand, printManCommand, printMarkdownCommand, } diff --git a/cmd/loop/recover.go b/cmd/loop/recover.go new file mode 100644 index 000000000..18d43a7d8 --- /dev/null +++ b/cmd/loop/recover.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + + "github.com/lightninglabs/loop/looprpc" + "github.com/urfave/cli/v3" +) + +var recoverCommand = &cli.Command{ + Name: "recover", + Usage: "restore static address and L402 state from a local backup file", + Description: "Restores the local static-address state and L402 token " + + "from an encrypted backup file. If --backup_file is omitted, " + + "loopd selects the latest decryptable active-network backup " + + "candidate and fully validates it before restoring state.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "backup_file", + Usage: "path to an encrypted backup file; if omitted, " + + "loopd selects and validates the latest active-network " + + "backup candidate", + }, + }, + Action: runRecover, +} + +func runRecover(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() + + resp, err := client.Recover( + ctx, &looprpc.RecoverRequest{ + BackupFile: cmd.String("backup_file"), + }, + ) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index c973a0028..48e203bb7 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -9,6 +9,7 @@ import ( "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" @@ -43,13 +44,15 @@ var staticAddressCommands = &cli.Command{ var newStaticAddressCommand = &cli.Command{ Name: "new", Aliases: []string{"n"}, - Usage: "Create a new static loop in address.", + Usage: "Return the 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. + Returns the current static loop in address. On a fresh installation loopd + initializes the current static-address generation during startup. If the + address is still missing, this call will create it on demand. 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, } @@ -59,16 +62,16 @@ func newStaticAddress(ctx context.Context, cmd *cli.Command) error { return showCommandHelp(ctx, cmd) } - err := displayNewAddressWarning() + client, cleanup, err := getClient(cmd) if err != nil { return err } + defer cleanup() - client, cleanup, err := getClient(cmd) + err = maybeDisplayNewAddressWarning(ctx, client) if err != nil { return err } - defer cleanup() resp, err := client.NewStaticAddress( ctx, &looprpc.NewStaticAddressRequest{}, @@ -846,8 +849,26 @@ 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 " + + fmt.Printf("\nWARNING: Be aware that losing your l402.token file in " + ".loop under your home directory will take your ability to " + "spend funds sent to the static address via loop-ins or " + "withdrawals. You will have to wait until the deposit " + 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..863e0b9de 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 @@ -13,13 +13,36 @@ "clock_start_unix": 1769407086 }, "events": [ + { + "time_ms": 1, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 1, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "error", + "error": "rpc error: code = Unknown desc = no static address parameters found", + "status": { + "code": 2, + "message": "no static address parameters found" + } + } + }, { "time_ms": 1, "kind": "stdout", "data": { "lines": [ "\n", - "WARNING: Be aware that loosing your l402.token file in .loop under your home directory will take your ability to spend funds sent to the static address via loop-ins or withdrawals. You will have to wait until the deposit expires and your loop client sweeps the funds back to your lnd wallet. The deposit expiry could be months in the future.\n", + "WARNING: Be aware that losing your l402.token file in .loop under your home directory will take your ability to spend funds sent to the static address via loop-ins or withdrawals. You will have to wait until the deposit expires and your loop client sweeps the funds back to your lnd wallet. The deposit expiry could be months in the future.\n", "\n", "CONTINUE WITH NEW ADDRESS? (y/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..69e93d4a0 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 @@ -24,7 +24,7 @@ " loop static [command [command options]]\n", "\n", "COMMANDS:\n", - " new, n Create a new static loop in address.\n", + " new, n Return the 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..61e3e5d4a 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -396,6 +396,16 @@ abandon a swap with a given swap hash .PP \fB--i_know_what_i_am_doing\fP: Specify this flag if you made sure that you read and understood the following consequence of applying this command. +.SH recover +.PP +restore static address and L402 state from a local backup file + +.PP +\fB--backup_file\fP="": path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate + +.PP +\fB--help, -h\fP: show help + .SH reservations, r .PP manage reservations @@ -449,7 +459,7 @@ perform on-chain to off-chain swaps using static addresses. .SS new, n .PP -Create a new static loop in address. +Return the static loop in address. .PP \fB--help, -h\fP: show help diff --git a/docs/loop.md b/docs/loop.md index 2f48e9471..2552757e4 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -419,6 +419,25 @@ The following flags are supported: | `--i_know_what_i_am_doing` | Specify this flag if you made sure that you read and understood the following consequence of applying this command | bool | `false` | | `--help` (`-h`) | show help | bool | `false` | +### `recover` command + +restore static address and L402 state from a local backup file. + +Restores the local static-address state and L402 token from an encrypted backup file. If --backup_file is omitted, loopd selects the latest decryptable active-network backup candidate and fully validates it before restoring state. + +Usage: + +```bash +$ loop [GLOBAL FLAGS] recover [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | +|---------------------|----------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--backup_file="…"` | path to an encrypted backup file; if omitted, loopd selects and validates the latest active-network backup candidate | string | +| `--help` (`-h`) | show help | bool | `false` | + ### `reservations` command (aliases: `r`) manage reservations. @@ -530,9 +549,9 @@ The following flags are supported: ### `static new` subcommand (aliases: `n`) -Create a new static loop in address. +Return the 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. +Returns the current static loop in address. On a fresh installation loopd initializes the current static-address generation during startup. If the address is still missing, this call will create it on demand. 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: diff --git a/go.mod b/go.mod index 1e312f90e..117d11a9f 100644 --- a/go.mod +++ b/go.mod @@ -191,7 +191,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.47.0 golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect diff --git a/loopd/daemon.go b/loopd/daemon.go index 68b761414..3fbb59403 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -22,6 +22,7 @@ import ( "github.com/lightninglabs/loop/loopdb" loop_looprpc "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/notifications" + "github.com/lightninglabs/loop/recovery" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" @@ -618,13 +619,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error { withdrawalManager *withdraw.Manager openChannelManager *openchannel.Manager staticLoopInManager *loopin.Manager + recoveryService *recovery.Service ) // Static address manager setup. staticAddressStore := address.NewSqlStore(baseDb) addrCfg := &address.ManagerConfig{ AddressClient: staticAddressClient, - FetchL402: swapClient.Server.FetchL402, + FetchL402: func(ctx context.Context) error { + return swapClient.Server.FetchL402(ctx) + }, Store: staticAddressStore, WalletKit: d.lnd.WalletKit, ChainParams: d.lnd.ChainParams, @@ -725,6 +729,46 @@ func (d *Daemon) initialize(withMacaroonService bool) error { return fmt.Errorf("unable to create loop-in manager: %w", err) } + // Keep startup restore/write-backup free of deposit reconciliation so we + // don't create deposit FSMs before the deposit manager is running. + startupRecoveryService := recovery.NewService( + d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit, + staticAddressManager, nil, + ) + + restoreResult, restoredFromBackup, err := + startupRecoveryService.RestoreLatestOnFreshInstall(d.mainCtx) + if err != nil { + return fmt.Errorf("unable to restore latest recovery "+ + "backup on fresh install: %w", err) + } + if restoredFromBackup { + infof("Restored fresh install from encrypted recovery "+ + "backup %s", restoreResult.BackupFile) + } else { + _, _, err = staticAddressManager.NewAddress(d.mainCtx) + if err != nil { + warnf("Unable to initialize static address generation "+ + "during startup: %v", err) + } + } + + backupFile, err := startupRecoveryService.WriteBackup(d.mainCtx) + if err != nil { + warnf("Unable to write startup recovery backup: %v", err) + } + if backupFile != "" { + infof("Wrote encrypted recovery backup to %s after "+ + "initializing the current L402 generation", backupFile) + } + + // Runtime recovery is wired with the deposit manager so explicit + // recovery RPCs can reconcile restored static-address deposits. + recoveryService = recovery.NewService( + d.cfg.DataDir, d.cfg.Network, d.lnd.Signer, d.lnd.WalletKit, + staticAddressManager, depositManager, + ) + var ( reservationManager *reservation.Manager instantOutManager *instantout.Manager @@ -793,6 +837,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { staticLoopInManager: staticLoopInManager, openChannelManager: openChannelManager, assetClient: d.assetClient, + recoveryService: recoveryService, stopDaemon: d.Stop, } diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d89dbbce2..53e98d1ac 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -29,6 +29,7 @@ import ( "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/recovery" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" @@ -101,6 +102,7 @@ type swapClientServer struct { staticLoopInManager *loopin.Manager openChannelManager *openchannel.Manager assetClient *assets.TapdClient + recoveryService *recovery.Service swaps map[lntypes.Hash]loop.SwapInfo subscribers map[int]chan<- any statusChan chan loop.SwapInfo @@ -1315,6 +1317,32 @@ func (s *swapClientServer) FetchL402Token(ctx context.Context, return &looprpc.FetchL402TokenResponse{}, nil } +// Recover restores the local paid L402 token material and static-address state +// from an encrypted backup file. +func (s *swapClientServer) Recover(ctx context.Context, + req *looprpc.RecoverRequest) (*looprpc.RecoverResponse, error) { + + if s.recoveryService == nil { + return nil, status.Error( + codes.Unavailable, "recovery service not configured", + ) + } + + result, err := s.recoveryService.Restore(ctx, req.GetBackupFile()) + if err != nil { + return nil, err + } + + return &looprpc.RecoverResponse{ + BackupFile: result.BackupFile, + RestoredL402: result.RestoredL402, + RestoredStaticAddress: result.RestoredStaticAddress, + StaticAddress: result.StaticAddress, + NumDepositsFound: uint32(result.NumDepositsFound), + DepositReconciliationError: result.DepositReconciliationError, + }, nil +} + // GetInfo returns basic information about the loop daemon and details to swaps // from the swap store. func (s *swapClientServer) GetInfo(ctx context.Context, @@ -1718,6 +1746,17 @@ func (s *swapClientServer) NewStaticAddress(ctx context.Context, return nil, err } + if s.recoveryService != nil { + backupFile, backupErr := s.recoveryService.WriteBackup(ctx) + if backupErr != nil { + warnf("Unable to write recovery backup after static "+ + "address request: %v", backupErr) + } else if backupFile != "" { + infof("Wrote encrypted recovery backup to %s after "+ + "static address request", backupFile) + } + } + return &looprpc.NewStaticAddressResponse{ Address: staticAddress.String(), Expiry: uint32(expiry), diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 3305bfcde..1fd42271a 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -3020,6 +3020,144 @@ func (*FetchL402TokenResponse) Descriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{29} } +type RecoverRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Optional path to the encrypted backup file. If omitted, loopd restores from + // the most recent immutable L402 recovery backup in the active network data + // directory. + BackupFile string `protobuf:"bytes,1,opt,name=backup_file,json=backupFile,proto3" json:"backup_file,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverRequest) Reset() { + *x = RecoverRequest{} + mi := &file_client_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverRequest) ProtoMessage() {} + +func (x *RecoverRequest) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[30] + 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 RecoverRequest.ProtoReflect.Descriptor instead. +func (*RecoverRequest) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{30} +} + +func (x *RecoverRequest) GetBackupFile() string { + if x != nil { + return x.BackupFile + } + return "" +} + +type RecoverResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The backup file that was restored. + BackupFile string `protobuf:"bytes,1,opt,name=backup_file,json=backupFile,proto3" json:"backup_file,omitempty"` + // Whether a paid L402 token was restored into the local token store. + RestoredL402 bool `protobuf:"varint,2,opt,name=restored_l402,json=restoredL402,proto3" json:"restored_l402,omitempty"` + // Whether static-address state was restored into loopd and lnd. + RestoredStaticAddress bool `protobuf:"varint,3,opt,name=restored_static_address,json=restoredStaticAddress,proto3" json:"restored_static_address,omitempty"` + // The restored static address, if any. + StaticAddress string `protobuf:"bytes,4,opt,name=static_address,json=staticAddress,proto3" json:"static_address,omitempty"` + // The number of deposits found during best-effort reconciliation. + NumDepositsFound uint32 `protobuf:"varint,5,opt,name=num_deposits_found,json=numDepositsFound,proto3" json:"num_deposits_found,omitempty"` + // Best-effort deposit reconciliation error text, if reconciliation failed + // after state restore completed. + DepositReconciliationError string `protobuf:"bytes,6,opt,name=deposit_reconciliation_error,json=depositReconciliationError,proto3" json:"deposit_reconciliation_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecoverResponse) Reset() { + *x = RecoverResponse{} + mi := &file_client_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecoverResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecoverResponse) ProtoMessage() {} + +func (x *RecoverResponse) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[31] + 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 RecoverResponse.ProtoReflect.Descriptor instead. +func (*RecoverResponse) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{31} +} + +func (x *RecoverResponse) GetBackupFile() string { + if x != nil { + return x.BackupFile + } + return "" +} + +func (x *RecoverResponse) GetRestoredL402() bool { + if x != nil { + return x.RestoredL402 + } + return false +} + +func (x *RecoverResponse) GetRestoredStaticAddress() bool { + if x != nil { + return x.RestoredStaticAddress + } + return false +} + +func (x *RecoverResponse) GetStaticAddress() string { + if x != nil { + return x.StaticAddress + } + return "" +} + +func (x *RecoverResponse) GetNumDepositsFound() uint32 { + if x != nil { + return x.NumDepositsFound + } + return 0 +} + +func (x *RecoverResponse) GetDepositReconciliationError() string { + if x != nil { + return x.DepositReconciliationError + } + return "" +} + type L402Token struct { state protoimpl.MessageState `protogen:"open.v1"` // The base macaroon that was baked by the auth server. @@ -3049,7 +3187,7 @@ type L402Token struct { func (x *L402Token) Reset() { *x = L402Token{} - mi := &file_client_proto_msgTypes[30] + mi := &file_client_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3061,7 +3199,7 @@ func (x *L402Token) String() string { func (*L402Token) ProtoMessage() {} func (x *L402Token) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[30] + mi := &file_client_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3074,7 +3212,7 @@ func (x *L402Token) ProtoReflect() protoreflect.Message { // Deprecated: Use L402Token.ProtoReflect.Descriptor instead. func (*L402Token) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{30} + return file_client_proto_rawDescGZIP(), []int{32} } func (x *L402Token) GetBaseMacaroon() []byte { @@ -3158,7 +3296,7 @@ type LoopStats struct { func (x *LoopStats) Reset() { *x = LoopStats{} - mi := &file_client_proto_msgTypes[31] + mi := &file_client_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3170,7 +3308,7 @@ func (x *LoopStats) String() string { func (*LoopStats) ProtoMessage() {} func (x *LoopStats) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[31] + mi := &file_client_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3183,7 +3321,7 @@ func (x *LoopStats) ProtoReflect() protoreflect.Message { // Deprecated: Use LoopStats.ProtoReflect.Descriptor instead. func (*LoopStats) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{31} + return file_client_proto_rawDescGZIP(), []int{33} } func (x *LoopStats) GetPendingCount() uint64 { @@ -3229,7 +3367,7 @@ type GetInfoRequest struct { func (x *GetInfoRequest) Reset() { *x = GetInfoRequest{} - mi := &file_client_proto_msgTypes[32] + mi := &file_client_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3241,7 +3379,7 @@ func (x *GetInfoRequest) String() string { func (*GetInfoRequest) ProtoMessage() {} func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[32] + mi := &file_client_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3254,7 +3392,7 @@ func (x *GetInfoRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoRequest.ProtoReflect.Descriptor instead. func (*GetInfoRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{32} + return file_client_proto_rawDescGZIP(), []int{34} } type GetInfoResponse struct { @@ -3285,7 +3423,7 @@ type GetInfoResponse struct { func (x *GetInfoResponse) Reset() { *x = GetInfoResponse{} - mi := &file_client_proto_msgTypes[33] + mi := &file_client_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3297,7 +3435,7 @@ func (x *GetInfoResponse) String() string { func (*GetInfoResponse) ProtoMessage() {} func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[33] + mi := &file_client_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3310,7 +3448,7 @@ func (x *GetInfoResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetInfoResponse.ProtoReflect.Descriptor instead. func (*GetInfoResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{33} + return file_client_proto_rawDescGZIP(), []int{35} } func (x *GetInfoResponse) GetVersion() string { @@ -3384,7 +3522,7 @@ type GetLiquidityParamsRequest struct { func (x *GetLiquidityParamsRequest) Reset() { *x = GetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[34] + mi := &file_client_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3396,7 +3534,7 @@ func (x *GetLiquidityParamsRequest) String() string { func (*GetLiquidityParamsRequest) ProtoMessage() {} func (x *GetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[34] + mi := &file_client_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3409,7 +3547,7 @@ func (x *GetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLiquidityParamsRequest.ProtoReflect.Descriptor instead. func (*GetLiquidityParamsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{34} + return file_client_proto_rawDescGZIP(), []int{36} } type LiquidityParameters struct { @@ -3521,7 +3659,7 @@ type LiquidityParameters struct { func (x *LiquidityParameters) Reset() { *x = LiquidityParameters{} - mi := &file_client_proto_msgTypes[35] + mi := &file_client_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3533,7 +3671,7 @@ func (x *LiquidityParameters) String() string { func (*LiquidityParameters) ProtoMessage() {} func (x *LiquidityParameters) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[35] + mi := &file_client_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3546,7 +3684,7 @@ func (x *LiquidityParameters) ProtoReflect() protoreflect.Message { // Deprecated: Use LiquidityParameters.ProtoReflect.Descriptor instead. func (*LiquidityParameters) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{35} + return file_client_proto_rawDescGZIP(), []int{37} } func (x *LiquidityParameters) GetRules() []*LiquidityRule { @@ -3763,7 +3901,7 @@ type EasyAssetAutoloopParams struct { func (x *EasyAssetAutoloopParams) Reset() { *x = EasyAssetAutoloopParams{} - mi := &file_client_proto_msgTypes[36] + mi := &file_client_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3775,7 +3913,7 @@ func (x *EasyAssetAutoloopParams) String() string { func (*EasyAssetAutoloopParams) ProtoMessage() {} func (x *EasyAssetAutoloopParams) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[36] + mi := &file_client_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3788,7 +3926,7 @@ func (x *EasyAssetAutoloopParams) ProtoReflect() protoreflect.Message { // Deprecated: Use EasyAssetAutoloopParams.ProtoReflect.Descriptor instead. func (*EasyAssetAutoloopParams) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{36} + return file_client_proto_rawDescGZIP(), []int{38} } func (x *EasyAssetAutoloopParams) GetEnabled() bool { @@ -3832,7 +3970,7 @@ type LiquidityRule struct { func (x *LiquidityRule) Reset() { *x = LiquidityRule{} - mi := &file_client_proto_msgTypes[37] + mi := &file_client_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3844,7 +3982,7 @@ func (x *LiquidityRule) String() string { func (*LiquidityRule) ProtoMessage() {} func (x *LiquidityRule) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[37] + mi := &file_client_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3857,7 +3995,7 @@ func (x *LiquidityRule) ProtoReflect() protoreflect.Message { // Deprecated: Use LiquidityRule.ProtoReflect.Descriptor instead. func (*LiquidityRule) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{37} + return file_client_proto_rawDescGZIP(), []int{39} } func (x *LiquidityRule) GetChannelId() uint64 { @@ -3915,7 +4053,7 @@ type SetLiquidityParamsRequest struct { func (x *SetLiquidityParamsRequest) Reset() { *x = SetLiquidityParamsRequest{} - mi := &file_client_proto_msgTypes[38] + mi := &file_client_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3927,7 +4065,7 @@ func (x *SetLiquidityParamsRequest) String() string { func (*SetLiquidityParamsRequest) ProtoMessage() {} func (x *SetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[38] + mi := &file_client_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3940,7 +4078,7 @@ func (x *SetLiquidityParamsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLiquidityParamsRequest.ProtoReflect.Descriptor instead. func (*SetLiquidityParamsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{38} + return file_client_proto_rawDescGZIP(), []int{40} } func (x *SetLiquidityParamsRequest) GetParameters() *LiquidityParameters { @@ -3958,7 +4096,7 @@ type SetLiquidityParamsResponse struct { func (x *SetLiquidityParamsResponse) Reset() { *x = SetLiquidityParamsResponse{} - mi := &file_client_proto_msgTypes[39] + mi := &file_client_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3970,7 +4108,7 @@ func (x *SetLiquidityParamsResponse) String() string { func (*SetLiquidityParamsResponse) ProtoMessage() {} func (x *SetLiquidityParamsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[39] + mi := &file_client_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3983,7 +4121,7 @@ func (x *SetLiquidityParamsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLiquidityParamsResponse.ProtoReflect.Descriptor instead. func (*SetLiquidityParamsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{39} + return file_client_proto_rawDescGZIP(), []int{41} } type SuggestSwapsRequest struct { @@ -3994,7 +4132,7 @@ type SuggestSwapsRequest struct { func (x *SuggestSwapsRequest) Reset() { *x = SuggestSwapsRequest{} - mi := &file_client_proto_msgTypes[40] + mi := &file_client_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4006,7 +4144,7 @@ func (x *SuggestSwapsRequest) String() string { func (*SuggestSwapsRequest) ProtoMessage() {} func (x *SuggestSwapsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[40] + mi := &file_client_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4019,7 +4157,7 @@ func (x *SuggestSwapsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestSwapsRequest.ProtoReflect.Descriptor instead. func (*SuggestSwapsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{40} + return file_client_proto_rawDescGZIP(), []int{42} } type Disqualified struct { @@ -4036,7 +4174,7 @@ type Disqualified struct { func (x *Disqualified) Reset() { *x = Disqualified{} - mi := &file_client_proto_msgTypes[41] + mi := &file_client_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4048,7 +4186,7 @@ func (x *Disqualified) String() string { func (*Disqualified) ProtoMessage() {} func (x *Disqualified) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[41] + mi := &file_client_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4061,7 +4199,7 @@ func (x *Disqualified) ProtoReflect() protoreflect.Message { // Deprecated: Use Disqualified.ProtoReflect.Descriptor instead. func (*Disqualified) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{41} + return file_client_proto_rawDescGZIP(), []int{43} } func (x *Disqualified) GetChannelId() uint64 { @@ -4102,7 +4240,7 @@ type SuggestSwapsResponse struct { func (x *SuggestSwapsResponse) Reset() { *x = SuggestSwapsResponse{} - mi := &file_client_proto_msgTypes[42] + mi := &file_client_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4114,7 +4252,7 @@ func (x *SuggestSwapsResponse) String() string { func (*SuggestSwapsResponse) ProtoMessage() {} func (x *SuggestSwapsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[42] + mi := &file_client_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4127,7 +4265,7 @@ func (x *SuggestSwapsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SuggestSwapsResponse.ProtoReflect.Descriptor instead. func (*SuggestSwapsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{42} + return file_client_proto_rawDescGZIP(), []int{44} } func (x *SuggestSwapsResponse) GetLoopOut() []*LoopOutRequest { @@ -4173,7 +4311,7 @@ type AbandonSwapRequest struct { func (x *AbandonSwapRequest) Reset() { *x = AbandonSwapRequest{} - mi := &file_client_proto_msgTypes[43] + mi := &file_client_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4185,7 +4323,7 @@ func (x *AbandonSwapRequest) String() string { func (*AbandonSwapRequest) ProtoMessage() {} func (x *AbandonSwapRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[43] + mi := &file_client_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4198,7 +4336,7 @@ func (x *AbandonSwapRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AbandonSwapRequest.ProtoReflect.Descriptor instead. func (*AbandonSwapRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{43} + return file_client_proto_rawDescGZIP(), []int{45} } func (x *AbandonSwapRequest) GetId() []byte { @@ -4223,7 +4361,7 @@ type AbandonSwapResponse struct { func (x *AbandonSwapResponse) Reset() { *x = AbandonSwapResponse{} - mi := &file_client_proto_msgTypes[44] + mi := &file_client_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4235,7 +4373,7 @@ func (x *AbandonSwapResponse) String() string { func (*AbandonSwapResponse) ProtoMessage() {} func (x *AbandonSwapResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[44] + mi := &file_client_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4248,7 +4386,7 @@ func (x *AbandonSwapResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AbandonSwapResponse.ProtoReflect.Descriptor instead. func (*AbandonSwapResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{44} + return file_client_proto_rawDescGZIP(), []int{46} } type ListReservationsRequest struct { @@ -4259,7 +4397,7 @@ type ListReservationsRequest struct { func (x *ListReservationsRequest) Reset() { *x = ListReservationsRequest{} - mi := &file_client_proto_msgTypes[45] + mi := &file_client_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4271,7 +4409,7 @@ func (x *ListReservationsRequest) String() string { func (*ListReservationsRequest) ProtoMessage() {} func (x *ListReservationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[45] + mi := &file_client_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4284,7 +4422,7 @@ func (x *ListReservationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListReservationsRequest.ProtoReflect.Descriptor instead. func (*ListReservationsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{45} + return file_client_proto_rawDescGZIP(), []int{47} } type ListReservationsResponse struct { @@ -4297,7 +4435,7 @@ type ListReservationsResponse struct { func (x *ListReservationsResponse) Reset() { *x = ListReservationsResponse{} - mi := &file_client_proto_msgTypes[46] + mi := &file_client_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4309,7 +4447,7 @@ func (x *ListReservationsResponse) String() string { func (*ListReservationsResponse) ProtoMessage() {} func (x *ListReservationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[46] + mi := &file_client_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4322,7 +4460,7 @@ func (x *ListReservationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListReservationsResponse.ProtoReflect.Descriptor instead. func (*ListReservationsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{46} + return file_client_proto_rawDescGZIP(), []int{48} } func (x *ListReservationsResponse) GetReservations() []*ClientReservation { @@ -4352,7 +4490,7 @@ type ClientReservation struct { func (x *ClientReservation) Reset() { *x = ClientReservation{} - mi := &file_client_proto_msgTypes[47] + mi := &file_client_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4364,7 +4502,7 @@ func (x *ClientReservation) String() string { func (*ClientReservation) ProtoMessage() {} func (x *ClientReservation) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[47] + mi := &file_client_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4377,7 +4515,7 @@ func (x *ClientReservation) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientReservation.ProtoReflect.Descriptor instead. func (*ClientReservation) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{47} + return file_client_proto_rawDescGZIP(), []int{49} } func (x *ClientReservation) GetReservationId() []byte { @@ -4439,7 +4577,7 @@ type InstantOutRequest struct { func (x *InstantOutRequest) Reset() { *x = InstantOutRequest{} - mi := &file_client_proto_msgTypes[48] + mi := &file_client_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4451,7 +4589,7 @@ func (x *InstantOutRequest) String() string { func (*InstantOutRequest) ProtoMessage() {} func (x *InstantOutRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[48] + mi := &file_client_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4464,7 +4602,7 @@ func (x *InstantOutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutRequest.ProtoReflect.Descriptor instead. func (*InstantOutRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{48} + return file_client_proto_rawDescGZIP(), []int{50} } func (x *InstantOutRequest) GetReservationIds() [][]byte { @@ -4502,7 +4640,7 @@ type InstantOutResponse struct { func (x *InstantOutResponse) Reset() { *x = InstantOutResponse{} - mi := &file_client_proto_msgTypes[49] + mi := &file_client_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4514,7 +4652,7 @@ func (x *InstantOutResponse) String() string { func (*InstantOutResponse) ProtoMessage() {} func (x *InstantOutResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[49] + mi := &file_client_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4527,7 +4665,7 @@ func (x *InstantOutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutResponse.ProtoReflect.Descriptor instead. func (*InstantOutResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{49} + return file_client_proto_rawDescGZIP(), []int{51} } func (x *InstantOutResponse) GetInstantOutHash() []byte { @@ -4568,7 +4706,7 @@ type InstantOutQuoteRequest struct { func (x *InstantOutQuoteRequest) Reset() { *x = InstantOutQuoteRequest{} - mi := &file_client_proto_msgTypes[50] + mi := &file_client_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4580,7 +4718,7 @@ func (x *InstantOutQuoteRequest) String() string { func (*InstantOutQuoteRequest) ProtoMessage() {} func (x *InstantOutQuoteRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[50] + mi := &file_client_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4593,7 +4731,7 @@ func (x *InstantOutQuoteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutQuoteRequest.ProtoReflect.Descriptor instead. func (*InstantOutQuoteRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{50} + return file_client_proto_rawDescGZIP(), []int{52} } func (x *InstantOutQuoteRequest) GetAmt() uint64 { @@ -4631,7 +4769,7 @@ type InstantOutQuoteResponse struct { func (x *InstantOutQuoteResponse) Reset() { *x = InstantOutQuoteResponse{} - mi := &file_client_proto_msgTypes[51] + mi := &file_client_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4643,7 +4781,7 @@ func (x *InstantOutQuoteResponse) String() string { func (*InstantOutQuoteResponse) ProtoMessage() {} func (x *InstantOutQuoteResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[51] + mi := &file_client_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4656,7 +4794,7 @@ func (x *InstantOutQuoteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOutQuoteResponse.ProtoReflect.Descriptor instead. func (*InstantOutQuoteResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{51} + return file_client_proto_rawDescGZIP(), []int{53} } func (x *InstantOutQuoteResponse) GetServiceFeeSat() int64 { @@ -4681,7 +4819,7 @@ type ListInstantOutsRequest struct { func (x *ListInstantOutsRequest) Reset() { *x = ListInstantOutsRequest{} - mi := &file_client_proto_msgTypes[52] + mi := &file_client_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4693,7 +4831,7 @@ func (x *ListInstantOutsRequest) String() string { func (*ListInstantOutsRequest) ProtoMessage() {} func (x *ListInstantOutsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[52] + mi := &file_client_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4706,7 +4844,7 @@ func (x *ListInstantOutsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListInstantOutsRequest.ProtoReflect.Descriptor instead. func (*ListInstantOutsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{52} + return file_client_proto_rawDescGZIP(), []int{54} } type ListInstantOutsResponse struct { @@ -4719,7 +4857,7 @@ type ListInstantOutsResponse struct { func (x *ListInstantOutsResponse) Reset() { *x = ListInstantOutsResponse{} - mi := &file_client_proto_msgTypes[53] + mi := &file_client_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4731,7 +4869,7 @@ func (x *ListInstantOutsResponse) String() string { func (*ListInstantOutsResponse) ProtoMessage() {} func (x *ListInstantOutsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[53] + mi := &file_client_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4744,7 +4882,7 @@ func (x *ListInstantOutsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListInstantOutsResponse.ProtoReflect.Descriptor instead. func (*ListInstantOutsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{53} + return file_client_proto_rawDescGZIP(), []int{55} } func (x *ListInstantOutsResponse) GetSwaps() []*InstantOut { @@ -4772,7 +4910,7 @@ type InstantOut struct { func (x *InstantOut) Reset() { *x = InstantOut{} - mi := &file_client_proto_msgTypes[54] + mi := &file_client_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4784,7 +4922,7 @@ func (x *InstantOut) String() string { func (*InstantOut) ProtoMessage() {} func (x *InstantOut) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[54] + mi := &file_client_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4797,7 +4935,7 @@ func (x *InstantOut) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantOut.ProtoReflect.Descriptor instead. func (*InstantOut) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{54} + return file_client_proto_rawDescGZIP(), []int{56} } func (x *InstantOut) GetSwapHash() []byte { @@ -4845,7 +4983,7 @@ type NewStaticAddressRequest struct { func (x *NewStaticAddressRequest) Reset() { *x = NewStaticAddressRequest{} - mi := &file_client_proto_msgTypes[55] + mi := &file_client_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4857,7 +4995,7 @@ func (x *NewStaticAddressRequest) String() string { func (*NewStaticAddressRequest) ProtoMessage() {} func (x *NewStaticAddressRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[55] + mi := &file_client_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4870,7 +5008,7 @@ func (x *NewStaticAddressRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NewStaticAddressRequest.ProtoReflect.Descriptor instead. func (*NewStaticAddressRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{55} + return file_client_proto_rawDescGZIP(), []int{57} } func (x *NewStaticAddressRequest) GetClientKey() []byte { @@ -4892,7 +5030,7 @@ type NewStaticAddressResponse struct { func (x *NewStaticAddressResponse) Reset() { *x = NewStaticAddressResponse{} - mi := &file_client_proto_msgTypes[56] + mi := &file_client_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4904,7 +5042,7 @@ func (x *NewStaticAddressResponse) String() string { func (*NewStaticAddressResponse) ProtoMessage() {} func (x *NewStaticAddressResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[56] + mi := &file_client_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4917,7 +5055,7 @@ func (x *NewStaticAddressResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NewStaticAddressResponse.ProtoReflect.Descriptor instead. func (*NewStaticAddressResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{56} + return file_client_proto_rawDescGZIP(), []int{58} } func (x *NewStaticAddressResponse) GetAddress() string { @@ -4947,7 +5085,7 @@ type ListUnspentDepositsRequest struct { func (x *ListUnspentDepositsRequest) Reset() { *x = ListUnspentDepositsRequest{} - mi := &file_client_proto_msgTypes[57] + mi := &file_client_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4959,7 +5097,7 @@ func (x *ListUnspentDepositsRequest) String() string { func (*ListUnspentDepositsRequest) ProtoMessage() {} func (x *ListUnspentDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[57] + mi := &file_client_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4972,7 +5110,7 @@ func (x *ListUnspentDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUnspentDepositsRequest.ProtoReflect.Descriptor instead. func (*ListUnspentDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{57} + return file_client_proto_rawDescGZIP(), []int{59} } func (x *ListUnspentDepositsRequest) GetMinConfs() int32 { @@ -4999,7 +5137,7 @@ type ListUnspentDepositsResponse struct { func (x *ListUnspentDepositsResponse) Reset() { *x = ListUnspentDepositsResponse{} - mi := &file_client_proto_msgTypes[58] + mi := &file_client_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5011,7 +5149,7 @@ func (x *ListUnspentDepositsResponse) String() string { func (*ListUnspentDepositsResponse) ProtoMessage() {} func (x *ListUnspentDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[58] + mi := &file_client_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5024,7 +5162,7 @@ func (x *ListUnspentDepositsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListUnspentDepositsResponse.ProtoReflect.Descriptor instead. func (*ListUnspentDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{58} + return file_client_proto_rawDescGZIP(), []int{60} } func (x *ListUnspentDepositsResponse) GetUtxos() []*Utxo { @@ -5050,7 +5188,7 @@ type Utxo struct { func (x *Utxo) Reset() { *x = Utxo{} - mi := &file_client_proto_msgTypes[59] + mi := &file_client_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5062,7 +5200,7 @@ func (x *Utxo) String() string { func (*Utxo) ProtoMessage() {} func (x *Utxo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[59] + mi := &file_client_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5075,7 +5213,7 @@ func (x *Utxo) ProtoReflect() protoreflect.Message { // Deprecated: Use Utxo.ProtoReflect.Descriptor instead. func (*Utxo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{59} + return file_client_proto_rawDescGZIP(), []int{61} } func (x *Utxo) GetStaticAddress() string { @@ -5128,7 +5266,7 @@ type WithdrawDepositsRequest struct { func (x *WithdrawDepositsRequest) Reset() { *x = WithdrawDepositsRequest{} - mi := &file_client_proto_msgTypes[60] + mi := &file_client_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5140,7 +5278,7 @@ func (x *WithdrawDepositsRequest) String() string { func (*WithdrawDepositsRequest) ProtoMessage() {} func (x *WithdrawDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[60] + mi := &file_client_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5153,7 +5291,7 @@ func (x *WithdrawDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WithdrawDepositsRequest.ProtoReflect.Descriptor instead. func (*WithdrawDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{60} + return file_client_proto_rawDescGZIP(), []int{62} } func (x *WithdrawDepositsRequest) GetOutpoints() []*lnrpc.OutPoint { @@ -5203,7 +5341,7 @@ type WithdrawDepositsResponse struct { func (x *WithdrawDepositsResponse) Reset() { *x = WithdrawDepositsResponse{} - mi := &file_client_proto_msgTypes[61] + mi := &file_client_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5215,7 +5353,7 @@ func (x *WithdrawDepositsResponse) String() string { func (*WithdrawDepositsResponse) ProtoMessage() {} func (x *WithdrawDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[61] + mi := &file_client_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5228,7 +5366,7 @@ func (x *WithdrawDepositsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WithdrawDepositsResponse.ProtoReflect.Descriptor instead. func (*WithdrawDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{61} + return file_client_proto_rawDescGZIP(), []int{63} } func (x *WithdrawDepositsResponse) GetWithdrawalTxHash() string { @@ -5257,7 +5395,7 @@ type ListStaticAddressDepositsRequest struct { func (x *ListStaticAddressDepositsRequest) Reset() { *x = ListStaticAddressDepositsRequest{} - mi := &file_client_proto_msgTypes[62] + mi := &file_client_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5269,7 +5407,7 @@ func (x *ListStaticAddressDepositsRequest) String() string { func (*ListStaticAddressDepositsRequest) ProtoMessage() {} func (x *ListStaticAddressDepositsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[62] + mi := &file_client_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5282,7 +5420,7 @@ func (x *ListStaticAddressDepositsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressDepositsRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressDepositsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{62} + return file_client_proto_rawDescGZIP(), []int{64} } func (x *ListStaticAddressDepositsRequest) GetStateFilter() DepositState { @@ -5309,7 +5447,7 @@ type ListStaticAddressDepositsResponse struct { func (x *ListStaticAddressDepositsResponse) Reset() { *x = ListStaticAddressDepositsResponse{} - mi := &file_client_proto_msgTypes[63] + mi := &file_client_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5321,7 +5459,7 @@ func (x *ListStaticAddressDepositsResponse) String() string { func (*ListStaticAddressDepositsResponse) ProtoMessage() {} func (x *ListStaticAddressDepositsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[63] + mi := &file_client_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5334,7 +5472,7 @@ func (x *ListStaticAddressDepositsResponse) ProtoReflect() protoreflect.Message // Deprecated: Use ListStaticAddressDepositsResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressDepositsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{63} + return file_client_proto_rawDescGZIP(), []int{65} } func (x *ListStaticAddressDepositsResponse) GetFilteredDeposits() []*Deposit { @@ -5352,7 +5490,7 @@ type ListStaticAddressWithdrawalRequest struct { func (x *ListStaticAddressWithdrawalRequest) Reset() { *x = ListStaticAddressWithdrawalRequest{} - mi := &file_client_proto_msgTypes[64] + mi := &file_client_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5364,7 +5502,7 @@ func (x *ListStaticAddressWithdrawalRequest) String() string { func (*ListStaticAddressWithdrawalRequest) ProtoMessage() {} func (x *ListStaticAddressWithdrawalRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[64] + mi := &file_client_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5377,7 +5515,7 @@ func (x *ListStaticAddressWithdrawalRequest) ProtoReflect() protoreflect.Message // Deprecated: Use ListStaticAddressWithdrawalRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressWithdrawalRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{64} + return file_client_proto_rawDescGZIP(), []int{66} } type ListStaticAddressWithdrawalResponse struct { @@ -5390,7 +5528,7 @@ type ListStaticAddressWithdrawalResponse struct { func (x *ListStaticAddressWithdrawalResponse) Reset() { *x = ListStaticAddressWithdrawalResponse{} - mi := &file_client_proto_msgTypes[65] + mi := &file_client_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5402,7 +5540,7 @@ func (x *ListStaticAddressWithdrawalResponse) String() string { func (*ListStaticAddressWithdrawalResponse) ProtoMessage() {} func (x *ListStaticAddressWithdrawalResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[65] + mi := &file_client_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5415,7 +5553,7 @@ func (x *ListStaticAddressWithdrawalResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use ListStaticAddressWithdrawalResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressWithdrawalResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{65} + return file_client_proto_rawDescGZIP(), []int{67} } func (x *ListStaticAddressWithdrawalResponse) GetWithdrawals() []*StaticAddressWithdrawal { @@ -5433,7 +5571,7 @@ type ListStaticAddressSwapsRequest struct { func (x *ListStaticAddressSwapsRequest) Reset() { *x = ListStaticAddressSwapsRequest{} - mi := &file_client_proto_msgTypes[66] + mi := &file_client_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5445,7 +5583,7 @@ func (x *ListStaticAddressSwapsRequest) String() string { func (*ListStaticAddressSwapsRequest) ProtoMessage() {} func (x *ListStaticAddressSwapsRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[66] + mi := &file_client_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5458,7 +5596,7 @@ func (x *ListStaticAddressSwapsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressSwapsRequest.ProtoReflect.Descriptor instead. func (*ListStaticAddressSwapsRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{66} + return file_client_proto_rawDescGZIP(), []int{68} } type ListStaticAddressSwapsResponse struct { @@ -5471,7 +5609,7 @@ type ListStaticAddressSwapsResponse struct { func (x *ListStaticAddressSwapsResponse) Reset() { *x = ListStaticAddressSwapsResponse{} - mi := &file_client_proto_msgTypes[67] + mi := &file_client_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5483,7 +5621,7 @@ func (x *ListStaticAddressSwapsResponse) String() string { func (*ListStaticAddressSwapsResponse) ProtoMessage() {} func (x *ListStaticAddressSwapsResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[67] + mi := &file_client_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5496,7 +5634,7 @@ func (x *ListStaticAddressSwapsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStaticAddressSwapsResponse.ProtoReflect.Descriptor instead. func (*ListStaticAddressSwapsResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{67} + return file_client_proto_rawDescGZIP(), []int{69} } func (x *ListStaticAddressSwapsResponse) GetSwaps() []*StaticAddressLoopInSwap { @@ -5514,7 +5652,7 @@ type StaticAddressSummaryRequest struct { func (x *StaticAddressSummaryRequest) Reset() { *x = StaticAddressSummaryRequest{} - mi := &file_client_proto_msgTypes[68] + mi := &file_client_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5526,7 +5664,7 @@ func (x *StaticAddressSummaryRequest) String() string { func (*StaticAddressSummaryRequest) ProtoMessage() {} func (x *StaticAddressSummaryRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[68] + mi := &file_client_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5539,7 +5677,7 @@ func (x *StaticAddressSummaryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressSummaryRequest.ProtoReflect.Descriptor instead. func (*StaticAddressSummaryRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{68} + return file_client_proto_rawDescGZIP(), []int{70} } type StaticAddressSummaryResponse struct { @@ -5570,7 +5708,7 @@ type StaticAddressSummaryResponse struct { func (x *StaticAddressSummaryResponse) Reset() { *x = StaticAddressSummaryResponse{} - mi := &file_client_proto_msgTypes[69] + mi := &file_client_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5582,7 +5720,7 @@ func (x *StaticAddressSummaryResponse) String() string { func (*StaticAddressSummaryResponse) ProtoMessage() {} func (x *StaticAddressSummaryResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[69] + mi := &file_client_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5595,7 +5733,7 @@ func (x *StaticAddressSummaryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressSummaryResponse.ProtoReflect.Descriptor instead. func (*StaticAddressSummaryResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{69} + return file_client_proto_rawDescGZIP(), []int{71} } func (x *StaticAddressSummaryResponse) GetStaticAddress() string { @@ -5692,7 +5830,7 @@ type Deposit struct { func (x *Deposit) Reset() { *x = Deposit{} - mi := &file_client_proto_msgTypes[70] + mi := &file_client_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5704,7 +5842,7 @@ func (x *Deposit) String() string { func (*Deposit) ProtoMessage() {} func (x *Deposit) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[70] + mi := &file_client_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5717,7 +5855,7 @@ func (x *Deposit) ProtoReflect() protoreflect.Message { // Deprecated: Use Deposit.ProtoReflect.Descriptor instead. func (*Deposit) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{70} + return file_client_proto_rawDescGZIP(), []int{72} } func (x *Deposit) GetId() []byte { @@ -5791,7 +5929,7 @@ type StaticAddressWithdrawal struct { func (x *StaticAddressWithdrawal) Reset() { *x = StaticAddressWithdrawal{} - mi := &file_client_proto_msgTypes[71] + mi := &file_client_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5803,7 +5941,7 @@ func (x *StaticAddressWithdrawal) String() string { func (*StaticAddressWithdrawal) ProtoMessage() {} func (x *StaticAddressWithdrawal) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[71] + mi := &file_client_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5816,7 +5954,7 @@ func (x *StaticAddressWithdrawal) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressWithdrawal.ProtoReflect.Descriptor instead. func (*StaticAddressWithdrawal) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{71} + return file_client_proto_rawDescGZIP(), []int{73} } func (x *StaticAddressWithdrawal) GetTxId() string { @@ -5891,7 +6029,7 @@ type StaticAddressLoopInSwap struct { func (x *StaticAddressLoopInSwap) Reset() { *x = StaticAddressLoopInSwap{} - mi := &file_client_proto_msgTypes[72] + mi := &file_client_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5903,7 +6041,7 @@ func (x *StaticAddressLoopInSwap) String() string { func (*StaticAddressLoopInSwap) ProtoMessage() {} func (x *StaticAddressLoopInSwap) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[72] + mi := &file_client_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5916,7 +6054,7 @@ func (x *StaticAddressLoopInSwap) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInSwap.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInSwap) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{72} + return file_client_proto_rawDescGZIP(), []int{74} } func (x *StaticAddressLoopInSwap) GetSwapHash() []byte { @@ -6049,7 +6187,7 @@ type StaticAddressLoopInRequest struct { func (x *StaticAddressLoopInRequest) Reset() { *x = StaticAddressLoopInRequest{} - mi := &file_client_proto_msgTypes[73] + mi := &file_client_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6061,7 +6199,7 @@ func (x *StaticAddressLoopInRequest) String() string { func (*StaticAddressLoopInRequest) ProtoMessage() {} func (x *StaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[73] + mi := &file_client_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6074,7 +6212,7 @@ func (x *StaticAddressLoopInRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInRequest.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{73} + return file_client_proto_rawDescGZIP(), []int{75} } func (x *StaticAddressLoopInRequest) GetOutpoints() []string { @@ -6194,7 +6332,7 @@ type StaticAddressLoopInResponse struct { func (x *StaticAddressLoopInResponse) Reset() { *x = StaticAddressLoopInResponse{} - mi := &file_client_proto_msgTypes[74] + mi := &file_client_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6206,7 +6344,7 @@ func (x *StaticAddressLoopInResponse) String() string { func (*StaticAddressLoopInResponse) ProtoMessage() {} func (x *StaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[74] + mi := &file_client_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6219,7 +6357,7 @@ func (x *StaticAddressLoopInResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StaticAddressLoopInResponse.ProtoReflect.Descriptor instead. func (*StaticAddressLoopInResponse) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{74} + return file_client_proto_rawDescGZIP(), []int{76} } func (x *StaticAddressLoopInResponse) GetSwapHash() []byte { @@ -6348,7 +6486,7 @@ type AssetLoopOutRequest struct { func (x *AssetLoopOutRequest) Reset() { *x = AssetLoopOutRequest{} - mi := &file_client_proto_msgTypes[75] + mi := &file_client_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6360,7 +6498,7 @@ func (x *AssetLoopOutRequest) String() string { func (*AssetLoopOutRequest) ProtoMessage() {} func (x *AssetLoopOutRequest) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[75] + mi := &file_client_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6373,7 +6511,7 @@ func (x *AssetLoopOutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetLoopOutRequest.ProtoReflect.Descriptor instead. func (*AssetLoopOutRequest) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{75} + return file_client_proto_rawDescGZIP(), []int{77} } func (x *AssetLoopOutRequest) GetAssetId() []byte { @@ -6428,7 +6566,7 @@ type AssetRfqInfo struct { func (x *AssetRfqInfo) Reset() { *x = AssetRfqInfo{} - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6440,7 +6578,7 @@ func (x *AssetRfqInfo) String() string { func (*AssetRfqInfo) ProtoMessage() {} func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6453,7 +6591,7 @@ func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetRfqInfo.ProtoReflect.Descriptor instead. func (*AssetRfqInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{76} + return file_client_proto_rawDescGZIP(), []int{78} } func (x *AssetRfqInfo) GetPrepayRfqId() []byte { @@ -6542,7 +6680,7 @@ type FixedPoint struct { func (x *FixedPoint) Reset() { *x = FixedPoint{} - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6554,7 +6692,7 @@ func (x *FixedPoint) String() string { func (*FixedPoint) ProtoMessage() {} func (x *FixedPoint) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6567,7 +6705,7 @@ func (x *FixedPoint) ProtoReflect() protoreflect.Message { // Deprecated: Use FixedPoint.ProtoReflect.Descriptor instead. func (*FixedPoint) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{77} + return file_client_proto_rawDescGZIP(), []int{79} } func (x *FixedPoint) GetCoefficient() string { @@ -6598,7 +6736,7 @@ type AssetLoopOutInfo struct { func (x *AssetLoopOutInfo) Reset() { *x = AssetLoopOutInfo{} - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6610,7 +6748,7 @@ func (x *AssetLoopOutInfo) String() string { func (*AssetLoopOutInfo) ProtoMessage() {} func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6623,7 +6761,7 @@ func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetLoopOutInfo.ProtoReflect.Descriptor instead. func (*AssetLoopOutInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{78} + return file_client_proto_rawDescGZIP(), []int{80} } func (x *AssetLoopOutInfo) GetAssetId() string { @@ -6821,7 +6959,18 @@ const file_client_proto_rawDesc = "" + "\x0eTokensResponse\x12*\n" + "\x06tokens\x18\x01 \x03(\v2\x12.looprpc.L402TokenR\x06tokens\"\x17\n" + "\x15FetchL402TokenRequest\"\x18\n" + - "\x16FetchL402TokenResponse\"\xcb\x02\n" + + "\x16FetchL402TokenResponse\"1\n" + + "\x0eRecoverRequest\x12\x1f\n" + + "\vbackup_file\x18\x01 \x01(\tR\n" + + "backupFile\"\xa6\x02\n" + + "\x0fRecoverResponse\x12\x1f\n" + + "\vbackup_file\x18\x01 \x01(\tR\n" + + "backupFile\x12#\n" + + "\rrestored_l402\x18\x02 \x01(\bR\frestoredL402\x126\n" + + "\x17restored_static_address\x18\x03 \x01(\bR\x15restoredStaticAddress\x12%\n" + + "\x0estatic_address\x18\x04 \x01(\tR\rstaticAddress\x12,\n" + + "\x12num_deposits_found\x18\x05 \x01(\rR\x10numDepositsFound\x12@\n" + + "\x1cdeposit_reconciliation_error\x18\x06 \x01(\tR\x1adepositReconciliationError\"\xcb\x02\n" + "\tL402Token\x12#\n" + "\rbase_macaroon\x18\x01 \x01(\fR\fbaseMacaroon\x12!\n" + "\fpayment_hash\x18\x02 \x01(\fR\vpaymentHash\x12)\n" + @@ -7165,7 +7314,7 @@ const file_client_proto_rawDesc = "" + "\x1eSUCCEEDED_TRANSITIONING_FAILED\x10\t\x12\x13\n" + "\x0fUNLOCK_DEPOSITS\x10\n" + "\x12\x1e\n" + - "\x1aFAILED_STATIC_ADDRESS_SWAP\x10\v2\xca\x14\n" + + "\x1aFAILED_STATIC_ADDRESS_SWAP\x10\v2\x88\x15\n" + "\n" + "SwapClient\x129\n" + "\aLoopOut\x12\x17.looprpc.LoopOutRequest\x1a\x15.looprpc.SwapResponse\x127\n" + @@ -7183,6 +7332,7 @@ const file_client_proto_rawDesc = "" + "\rGetL402Tokens\x12\x16.looprpc.TokensRequest\x1a\x17.looprpc.TokensResponse\x12@\n" + "\rGetLsatTokens\x12\x16.looprpc.TokensRequest\x1a\x17.looprpc.TokensResponse\x12Q\n" + "\x0eFetchL402Token\x12\x1e.looprpc.FetchL402TokenRequest\x1a\x1f.looprpc.FetchL402TokenResponse\x12<\n" + + "\aRecover\x12\x17.looprpc.RecoverRequest\x1a\x18.looprpc.RecoverResponse\x12<\n" + "\aGetInfo\x12\x17.looprpc.GetInfoRequest\x1a\x18.looprpc.GetInfoResponse\x12E\n" + "\n" + "StopDaemon\x12\x1a.looprpc.StopDaemonRequest\x1a\x1b.looprpc.StopDaemonResponse\x12V\n" + @@ -7217,7 +7367,7 @@ func file_client_proto_rawDescGZIP() []byte { } var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 80) +var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 82) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7259,119 +7409,121 @@ var file_client_proto_goTypes = []any{ (*TokensResponse)(nil), // 37: looprpc.TokensResponse (*FetchL402TokenRequest)(nil), // 38: looprpc.FetchL402TokenRequest (*FetchL402TokenResponse)(nil), // 39: looprpc.FetchL402TokenResponse - (*L402Token)(nil), // 40: looprpc.L402Token - (*LoopStats)(nil), // 41: looprpc.LoopStats - (*GetInfoRequest)(nil), // 42: looprpc.GetInfoRequest - (*GetInfoResponse)(nil), // 43: looprpc.GetInfoResponse - (*GetLiquidityParamsRequest)(nil), // 44: looprpc.GetLiquidityParamsRequest - (*LiquidityParameters)(nil), // 45: looprpc.LiquidityParameters - (*EasyAssetAutoloopParams)(nil), // 46: looprpc.EasyAssetAutoloopParams - (*LiquidityRule)(nil), // 47: looprpc.LiquidityRule - (*SetLiquidityParamsRequest)(nil), // 48: looprpc.SetLiquidityParamsRequest - (*SetLiquidityParamsResponse)(nil), // 49: looprpc.SetLiquidityParamsResponse - (*SuggestSwapsRequest)(nil), // 50: looprpc.SuggestSwapsRequest - (*Disqualified)(nil), // 51: looprpc.Disqualified - (*SuggestSwapsResponse)(nil), // 52: looprpc.SuggestSwapsResponse - (*AbandonSwapRequest)(nil), // 53: looprpc.AbandonSwapRequest - (*AbandonSwapResponse)(nil), // 54: looprpc.AbandonSwapResponse - (*ListReservationsRequest)(nil), // 55: looprpc.ListReservationsRequest - (*ListReservationsResponse)(nil), // 56: looprpc.ListReservationsResponse - (*ClientReservation)(nil), // 57: looprpc.ClientReservation - (*InstantOutRequest)(nil), // 58: looprpc.InstantOutRequest - (*InstantOutResponse)(nil), // 59: looprpc.InstantOutResponse - (*InstantOutQuoteRequest)(nil), // 60: looprpc.InstantOutQuoteRequest - (*InstantOutQuoteResponse)(nil), // 61: looprpc.InstantOutQuoteResponse - (*ListInstantOutsRequest)(nil), // 62: looprpc.ListInstantOutsRequest - (*ListInstantOutsResponse)(nil), // 63: looprpc.ListInstantOutsResponse - (*InstantOut)(nil), // 64: looprpc.InstantOut - (*NewStaticAddressRequest)(nil), // 65: looprpc.NewStaticAddressRequest - (*NewStaticAddressResponse)(nil), // 66: looprpc.NewStaticAddressResponse - (*ListUnspentDepositsRequest)(nil), // 67: looprpc.ListUnspentDepositsRequest - (*ListUnspentDepositsResponse)(nil), // 68: looprpc.ListUnspentDepositsResponse - (*Utxo)(nil), // 69: looprpc.Utxo - (*WithdrawDepositsRequest)(nil), // 70: looprpc.WithdrawDepositsRequest - (*WithdrawDepositsResponse)(nil), // 71: looprpc.WithdrawDepositsResponse - (*ListStaticAddressDepositsRequest)(nil), // 72: looprpc.ListStaticAddressDepositsRequest - (*ListStaticAddressDepositsResponse)(nil), // 73: looprpc.ListStaticAddressDepositsResponse - (*ListStaticAddressWithdrawalRequest)(nil), // 74: looprpc.ListStaticAddressWithdrawalRequest - (*ListStaticAddressWithdrawalResponse)(nil), // 75: looprpc.ListStaticAddressWithdrawalResponse - (*ListStaticAddressSwapsRequest)(nil), // 76: looprpc.ListStaticAddressSwapsRequest - (*ListStaticAddressSwapsResponse)(nil), // 77: looprpc.ListStaticAddressSwapsResponse - (*StaticAddressSummaryRequest)(nil), // 78: looprpc.StaticAddressSummaryRequest - (*StaticAddressSummaryResponse)(nil), // 79: looprpc.StaticAddressSummaryResponse - (*Deposit)(nil), // 80: looprpc.Deposit - (*StaticAddressWithdrawal)(nil), // 81: looprpc.StaticAddressWithdrawal - (*StaticAddressLoopInSwap)(nil), // 82: looprpc.StaticAddressLoopInSwap - (*StaticAddressLoopInRequest)(nil), // 83: looprpc.StaticAddressLoopInRequest - (*StaticAddressLoopInResponse)(nil), // 84: looprpc.StaticAddressLoopInResponse - (*AssetLoopOutRequest)(nil), // 85: looprpc.AssetLoopOutRequest - (*AssetRfqInfo)(nil), // 86: looprpc.AssetRfqInfo - (*FixedPoint)(nil), // 87: looprpc.FixedPoint - (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo - nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint + (*RecoverRequest)(nil), // 40: looprpc.RecoverRequest + (*RecoverResponse)(nil), // 41: looprpc.RecoverResponse + (*L402Token)(nil), // 42: looprpc.L402Token + (*LoopStats)(nil), // 43: looprpc.LoopStats + (*GetInfoRequest)(nil), // 44: looprpc.GetInfoRequest + (*GetInfoResponse)(nil), // 45: looprpc.GetInfoResponse + (*GetLiquidityParamsRequest)(nil), // 46: looprpc.GetLiquidityParamsRequest + (*LiquidityParameters)(nil), // 47: looprpc.LiquidityParameters + (*EasyAssetAutoloopParams)(nil), // 48: looprpc.EasyAssetAutoloopParams + (*LiquidityRule)(nil), // 49: looprpc.LiquidityRule + (*SetLiquidityParamsRequest)(nil), // 50: looprpc.SetLiquidityParamsRequest + (*SetLiquidityParamsResponse)(nil), // 51: looprpc.SetLiquidityParamsResponse + (*SuggestSwapsRequest)(nil), // 52: looprpc.SuggestSwapsRequest + (*Disqualified)(nil), // 53: looprpc.Disqualified + (*SuggestSwapsResponse)(nil), // 54: looprpc.SuggestSwapsResponse + (*AbandonSwapRequest)(nil), // 55: looprpc.AbandonSwapRequest + (*AbandonSwapResponse)(nil), // 56: looprpc.AbandonSwapResponse + (*ListReservationsRequest)(nil), // 57: looprpc.ListReservationsRequest + (*ListReservationsResponse)(nil), // 58: looprpc.ListReservationsResponse + (*ClientReservation)(nil), // 59: looprpc.ClientReservation + (*InstantOutRequest)(nil), // 60: looprpc.InstantOutRequest + (*InstantOutResponse)(nil), // 61: looprpc.InstantOutResponse + (*InstantOutQuoteRequest)(nil), // 62: looprpc.InstantOutQuoteRequest + (*InstantOutQuoteResponse)(nil), // 63: looprpc.InstantOutQuoteResponse + (*ListInstantOutsRequest)(nil), // 64: looprpc.ListInstantOutsRequest + (*ListInstantOutsResponse)(nil), // 65: looprpc.ListInstantOutsResponse + (*InstantOut)(nil), // 66: looprpc.InstantOut + (*NewStaticAddressRequest)(nil), // 67: looprpc.NewStaticAddressRequest + (*NewStaticAddressResponse)(nil), // 68: looprpc.NewStaticAddressResponse + (*ListUnspentDepositsRequest)(nil), // 69: looprpc.ListUnspentDepositsRequest + (*ListUnspentDepositsResponse)(nil), // 70: looprpc.ListUnspentDepositsResponse + (*Utxo)(nil), // 71: looprpc.Utxo + (*WithdrawDepositsRequest)(nil), // 72: looprpc.WithdrawDepositsRequest + (*WithdrawDepositsResponse)(nil), // 73: looprpc.WithdrawDepositsResponse + (*ListStaticAddressDepositsRequest)(nil), // 74: looprpc.ListStaticAddressDepositsRequest + (*ListStaticAddressDepositsResponse)(nil), // 75: looprpc.ListStaticAddressDepositsResponse + (*ListStaticAddressWithdrawalRequest)(nil), // 76: looprpc.ListStaticAddressWithdrawalRequest + (*ListStaticAddressWithdrawalResponse)(nil), // 77: looprpc.ListStaticAddressWithdrawalResponse + (*ListStaticAddressSwapsRequest)(nil), // 78: looprpc.ListStaticAddressSwapsRequest + (*ListStaticAddressSwapsResponse)(nil), // 79: looprpc.ListStaticAddressSwapsResponse + (*StaticAddressSummaryRequest)(nil), // 80: looprpc.StaticAddressSummaryRequest + (*StaticAddressSummaryResponse)(nil), // 81: looprpc.StaticAddressSummaryResponse + (*Deposit)(nil), // 82: looprpc.Deposit + (*StaticAddressWithdrawal)(nil), // 83: looprpc.StaticAddressWithdrawal + (*StaticAddressLoopInSwap)(nil), // 84: looprpc.StaticAddressLoopInSwap + (*StaticAddressLoopInRequest)(nil), // 85: looprpc.StaticAddressLoopInRequest + (*StaticAddressLoopInResponse)(nil), // 86: looprpc.StaticAddressLoopInResponse + (*AssetLoopOutRequest)(nil), // 87: looprpc.AssetLoopOutRequest + (*AssetRfqInfo)(nil), // 88: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 89: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 90: looprpc.AssetLoopOutInfo + nil, // 91: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 92: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 93: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 92, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType - 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 91, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 87, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 88, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 93, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 3, // 7: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 88, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 90, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo 20, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter 9, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter 18, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus 24, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested 25, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded 26, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 91, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 85, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 91, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 40, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 41, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 41, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 47, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 93, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 87, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 88, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 93, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 42, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 43, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 43, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 49, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule 0, // 23: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 89, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 91, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry 4, // 25: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource 1, // 26: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType 5, // 27: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 45, // 28: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 47, // 28: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters 6, // 29: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason 14, // 30: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest 15, // 31: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 83, // 32: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest - 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 + 85, // 32: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 53, // 33: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 59, // 34: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 66, // 35: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 71, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 94, // 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 + 82, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 83, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 84, // 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 + 82, // 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 + 82, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 93, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 82, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 89, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 89, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 48, // 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 + 55, // 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 @@ -7380,59 +7532,61 @@ var file_client_proto_depIdxs = []int32{ 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 + 40, // 66: looprpc.SwapClient.Recover:input_type -> looprpc.RecoverRequest + 44, // 67: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 68: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 46, // 69: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 50, // 70: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 52, // 71: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 57, // 72: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 60, // 73: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 62, // 74: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 64, // 75: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 67, // 76: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 69, // 77: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 72, // 78: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 74, // 79: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 76, // 80: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 78, // 81: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 80, // 82: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 85, // 83: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 84: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 85: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 86: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 87: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 88: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 89: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 90: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 56, // 91: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 92: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 93: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 94: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 95: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 96: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 97: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 98: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 99: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 41, // 100: looprpc.SwapClient.Recover:output_type -> looprpc.RecoverResponse + 45, // 101: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 102: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 47, // 103: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 51, // 104: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 54, // 105: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 58, // 106: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 61, // 107: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 63, // 108: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 65, // 109: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 68, // 110: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 70, // 111: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 73, // 112: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 75, // 113: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 77, // 114: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 79, // 115: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 81, // 116: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 86, // 117: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 118: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 85, // [85:119] is the sub-list for method output_type + 51, // [51:85] 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 @@ -7454,7 +7608,7 @@ func file_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), NumEnums: 10, - NumMessages: 80, + NumMessages: 82, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.pb.gw.go b/looprpc/client.pb.gw.go index c2ef63f88..f80f4ce6b 100644 --- a/looprpc/client.pb.gw.go +++ b/looprpc/client.pb.gw.go @@ -479,6 +479,32 @@ func local_request_SwapClient_GetL402Tokens_1(ctx context.Context, marshaler run } +func request_SwapClient_Recover_0(ctx context.Context, marshaler runtime.Marshaler, client SwapClientClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.Recover(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_SwapClient_Recover_0(ctx context.Context, marshaler runtime.Marshaler, server SwapClientServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq RecoverRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.Recover(ctx, &protoReq) + return msg, metadata, err + +} + func request_SwapClient_GetInfo_0(ctx context.Context, marshaler runtime.Marshaler, client SwapClientClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq GetInfoRequest var metadata runtime.ServerMetadata @@ -1175,6 +1201,31 @@ func RegisterSwapClientHandlerServer(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_SwapClient_Recover_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/looprpc.SwapClient/Recover", runtime.WithHTTPPathPattern("/v1/recover")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_SwapClient_Recover_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_SwapClient_Recover_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("GET", pattern_SwapClient_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1905,6 +1956,28 @@ func RegisterSwapClientHandlerClient(ctx context.Context, mux *runtime.ServeMux, }) + mux.Handle("POST", pattern_SwapClient_Recover_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/looprpc.SwapClient/Recover", runtime.WithHTTPPathPattern("/v1/recover")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_SwapClient_Recover_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_SwapClient_Recover_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("GET", pattern_SwapClient_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -2307,6 +2380,8 @@ var ( pattern_SwapClient_GetL402Tokens_1 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "lsat", "tokens"}, "")) + pattern_SwapClient_Recover_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "recover"}, "")) + pattern_SwapClient_GetInfo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "loop", "info"}, "")) pattern_SwapClient_StopDaemon_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "daemon", "stop"}, "")) @@ -2367,6 +2442,8 @@ var ( forward_SwapClient_GetL402Tokens_1 = runtime.ForwardResponseMessage + forward_SwapClient_Recover_0 = runtime.ForwardResponseMessage + forward_SwapClient_GetInfo_0 = runtime.ForwardResponseMessage forward_SwapClient_StopDaemon_0 = runtime.ForwardResponseMessage diff --git a/looprpc/client.proto b/looprpc/client.proto index 3ae690dc3..ed8c08c1f 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -102,6 +102,12 @@ service SwapClient { */ rpc FetchL402Token (FetchL402TokenRequest) returns (FetchL402TokenResponse); + /* loop: `recover` + Recover restores the local static-address and L402 state from an encrypted + local backup file. + */ + rpc Recover (RecoverRequest) returns (RecoverResponse); + /* loop: `getinfo` GetInfo gets basic information about the loop daemon. */ @@ -1060,6 +1066,48 @@ message FetchL402TokenRequest { message FetchL402TokenResponse { } +message RecoverRequest { + /* + Optional path to the encrypted backup file. If omitted, loopd restores from + the most recent immutable L402 recovery backup in the active network data + directory. + */ + string backup_file = 1; +} + +message RecoverResponse { + /* + The backup file that was restored. + */ + string backup_file = 1; + + /* + Whether a paid L402 token was restored into the local token store. + */ + bool restored_l402 = 2; + + /* + Whether static-address state was restored into loopd and lnd. + */ + bool restored_static_address = 3; + + /* + The restored static address, if any. + */ + string static_address = 4; + + /* + The number of deposits found during best-effort reconciliation. + */ + uint32 num_deposits_found = 5; + + /* + Best-effort deposit reconciliation error text, if reconciliation failed + after state restore completed. + */ + string deposit_reconciliation_error = 6; +} + message L402Token { /* The base macaroon that was baked by the auth server. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index a50814e4e..89d164006 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -868,6 +868,39 @@ ] } }, + "/v1/recover": { + "post": { + "summary": "loop: `recover`\nRecover restores the local static-address and L402 state from an encrypted\nlocal backup file.", + "operationId": "SwapClient_Recover", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/looprpcRecoverResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/looprpcRecoverRequest" + } + } + ], + "tags": [ + "SwapClient" + ] + } + }, "/v1/staticaddr": { "post": { "summary": "loop: `static newstaticaddress`\nNewStaticAddress requests a new static address for loop-ins from the server.", @@ -2616,6 +2649,45 @@ "type": "object", "description": "PublishSucceeded is returned by SweepHtlc if publishing was requested in\nSweepHtlcRequest and it succeeded." }, + "looprpcRecoverRequest": { + "type": "object", + "properties": { + "backup_file": { + "type": "string", + "description": "Optional path to the encrypted backup file. If omitted, loopd restores from\nthe most recent immutable L402 recovery backup in the active network data\ndirectory." + } + } + }, + "looprpcRecoverResponse": { + "type": "object", + "properties": { + "backup_file": { + "type": "string", + "description": "The backup file that was restored." + }, + "restored_l402": { + "type": "boolean", + "description": "Whether a paid L402 token was restored into the local token store." + }, + "restored_static_address": { + "type": "boolean", + "description": "Whether static-address state was restored into loopd and lnd." + }, + "static_address": { + "type": "string", + "description": "The restored static address, if any." + }, + "num_deposits_found": { + "type": "integer", + "format": "int64", + "description": "The number of deposits found during best-effort reconciliation." + }, + "deposit_reconciliation_error": { + "type": "string", + "description": "Best-effort deposit reconciliation error text, if reconciliation failed\nafter state restore completed." + } + } + }, "looprpcRouteHint": { "type": "object", "properties": { diff --git a/looprpc/client.yaml b/looprpc/client.yaml index 5213afe4d..88c038b89 100644 --- a/looprpc/client.yaml +++ b/looprpc/client.yaml @@ -33,6 +33,9 @@ http: get: "/v1/l402/tokens" additional_bindings: - get: "/v1/lsat/tokens" + - selector: looprpc.SwapClient.Recover + post: "/v1/recover" + body: "*" - selector: looprpc.SwapClient.GetLiquidityParams get: "/v1/liquidity/params" - selector: looprpc.SwapClient.SetLiquidityParams diff --git a/looprpc/client_grpc.pb.go b/looprpc/client_grpc.pb.go index b03cc9e87..47fdbd0ef 100644 --- a/looprpc/client_grpc.pb.go +++ b/looprpc/client_grpc.pb.go @@ -76,6 +76,10 @@ type SwapClientClient interface { // FetchL402Token fetches an L402 token from the server, this is required in // order to receive reservation notifications from the server. FetchL402Token(ctx context.Context, in *FetchL402TokenRequest, opts ...grpc.CallOption) (*FetchL402TokenResponse, error) + // loop: `recover` + // Recover restores the local static-address and L402 state from an encrypted + // local backup file. + Recover(ctx context.Context, in *RecoverRequest, opts ...grpc.CallOption) (*RecoverResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) @@ -312,6 +316,15 @@ func (c *swapClientClient) FetchL402Token(ctx context.Context, in *FetchL402Toke return out, nil } +func (c *swapClientClient) Recover(ctx context.Context, in *RecoverRequest, opts ...grpc.CallOption) (*RecoverResponse, error) { + out := new(RecoverResponse) + err := c.cc.Invoke(ctx, "/looprpc.SwapClient/Recover", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *swapClientClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { out := new(GetInfoResponse) err := c.cc.Invoke(ctx, "/looprpc.SwapClient/GetInfo", in, out, opts...) @@ -536,6 +549,10 @@ type SwapClientServer interface { // FetchL402Token fetches an L402 token from the server, this is required in // order to receive reservation notifications from the server. FetchL402Token(context.Context, *FetchL402TokenRequest) (*FetchL402TokenResponse, error) + // loop: `recover` + // Recover restores the local static-address and L402 state from an encrypted + // local backup file. + Recover(context.Context, *RecoverRequest) (*RecoverResponse, error) // loop: `getinfo` // GetInfo gets basic information about the loop daemon. GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) @@ -656,6 +673,9 @@ func (UnimplementedSwapClientServer) GetLsatTokens(context.Context, *TokensReque func (UnimplementedSwapClientServer) FetchL402Token(context.Context, *FetchL402TokenRequest) (*FetchL402TokenResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method FetchL402Token not implemented") } +func (UnimplementedSwapClientServer) Recover(context.Context, *RecoverRequest) (*RecoverResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Recover not implemented") +} func (UnimplementedSwapClientServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetInfo not implemented") } @@ -996,6 +1016,24 @@ func _SwapClient_FetchL402Token_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _SwapClient_Recover_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecoverRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(SwapClientServer).Recover(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/looprpc.SwapClient/Recover", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(SwapClientServer).Recover(ctx, req.(*RecoverRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _SwapClient_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetInfoRequest) if err := dec(in); err != nil { @@ -1383,6 +1421,10 @@ var SwapClient_ServiceDesc = grpc.ServiceDesc{ MethodName: "FetchL402Token", Handler: _SwapClient_FetchL402Token_Handler, }, + { + MethodName: "Recover", + Handler: _SwapClient_Recover_Handler, + }, { MethodName: "GetInfo", Handler: _SwapClient_GetInfo_Handler, diff --git a/looprpc/perms.go b/looprpc/perms.go index d646f6671..78952e714 100644 --- a/looprpc/perms.go +++ b/looprpc/perms.go @@ -151,6 +151,13 @@ var RequiredPermissions = map[string][]bakery.Op{ Entity: "auth", Action: "write", }}, + "/looprpc.SwapClient/Recover": {{ + Entity: "auth", + Action: "write", + }, { + Entity: "loop", + Action: "in", + }}, "/looprpc.SwapClient/SuggestSwaps": {{ Entity: "suggestions", Action: "read", diff --git a/looprpc/swapclient.pb.json.go b/looprpc/swapclient.pb.json.go index ef1297dc3..a168d8152 100644 --- a/looprpc/swapclient.pb.json.go +++ b/looprpc/swapclient.pb.json.go @@ -413,6 +413,31 @@ func RegisterSwapClientJSONCallbacks(registry map[string]func(ctx context.Contex callback(string(respBytes), nil) } + registry["looprpc.SwapClient.Recover"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &RecoverRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewSwapClientClient(conn) + resp, err := client.Recover(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } + registry["looprpc.SwapClient.GetInfo"] = func(ctx context.Context, conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { diff --git a/recovery/README.md b/recovery/README.md new file mode 100644 index 000000000..81cc48cf2 --- /dev/null +++ b/recovery/README.md @@ -0,0 +1,336 @@ +# Recovery Package + +This package implements local recovery for Loop's static-address and L402 +state. + +## Goal + +Recovery is generation-based. In this package, a generation is anchored by: + +- one paid L402 token +- the static-address parameters tied to that L402 + +The current V0 static-address implementation represents a generation locally as +one concrete static address. The backup stores the fields needed to recreate +that concrete address today and also stores the stable receive/change +key-family metadata planned multi-address recovery will scan from later. The +backup itself is not rewritten when later code issues more addresses. + +The recovery flow is designed to let a fresh or repaired Loop instance rebuild +that generation after local disk loss, data-directory replacement, or partial +corruption. + +Recovery uses a single immutable backup per L402 generation. Once written, +that backup file is never updated in place. + +## Backup Model + +The daemon writes at most one encrypted backup file for each paid L402 token +ID: + +`/L402_backup__.enc` + +In the normal layout this resolves inside the active network-specific Loop data +directory, for example: + +`~/.loop/mainnet/L402_backup_1776159001000000000_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.enc` + +If `loop recover` is called without `--backup_file`, Loop scans the active +network directory for files with this name shape. It decrypts candidates with +the local lnd-derived key, filters them by network, validates the complete +recoverable generation against the filename metadata, and selects the valid +candidate with the latest timestamp in its filename. + +## What Is Backed Up + +Each encrypted backup stores: + +- a backup format version +- the Bitcoin network +- the paid L402 token ID +- the paid L402 token creation time +- the raw paid `l402.token` file +- the static-address protocol version +- the L402-bound server pubkey +- the static-address client pubkey +- the static-address expiry +- the legacy concrete static-address client key family +- the planned multi-address receive key family +- the planned multi-address change key family +- the legacy first height +- the multi-address first height + +The static-address fields are written once. The L402-bound server pubkey, +protocol version, expiry, planned multi-address receive/change key families, +Bitcoin network, and multi-address first height are the stable address-space +metadata for future scanning. The stored client pubkey, legacy client key +family, and legacy first height let the current V0 restore path find the +matching wallet child and recreate the one concrete static-address row. + +Current V0 backups initialize `legacy_first_height` from the legacy concrete +static-address initiation height and `multi_address_first_height` from the +current block height when the backup is written. They are separate fields so +the future multi-address scan floor is independent from the legacy concrete +address import hint. + +The Taproot address string, `pkScript`, and scan lookahead/gap limit are not +backed up. The address and `pkScript` can be derived from the stored key and +script parameters, and the gap limit is restore policy rather than immutable +backup data. + +The L402 file is preserved as a raw blob so restore remains compatible with the +Aperture token-store file format. + +Deposit FSM state is not serialized into the backup. After restore, the deposit +manager asks lnd for wallet-visible static-address UTXOs and recreates active +deposit state from that view. Historical finalized or spent deposit transitions +are not replayed from the backup. + +## Why Root And Legacy Fields Are Both Stored + +The server pubkey, protocol version, expiry, planned multi-address +receive/change key families, Bitcoin network, and multi-address first height +define the stable fields future restore code will combine with lnd-derived +client keys and a chain scan. They are not used by the current V0 restore path. + +The current V0 restore path recreates the existing concrete address row +directly, so the backup also stores that row's client pubkey, legacy client key +family, and legacy first height. Those fields let restore find the matching +local wallet child and import the concrete address from the right chain height. + +## Encryption Model + +The file is encrypted with `secretbox` using a symmetric key derived from lnd +via `Signer.DeriveSharedKey`. + +The derivation uses: + +- a fixed NUMS public key +- the legacy static-address key family +- key index `0` + +This ties backup decryption to the same lnd seed that controls the static +address keys without introducing a user-managed recovery password in this +implementation. + +Operationally, this means the backup is not standalone. Loop cannot decrypt or +restore it without the backing `lnd` wallet that can derive the same key. A +replacement `lnd` restored from the same seed/key material is sufficient, but an +unrelated `lnd` is not. Keep the encrypted Loop backup together with the +corresponding `lnd` recovery material; the Loop backup file by itself is not +enough to recover static-address access. + +## When Backups Are Written + +The backup is only written once a complete recoverable generation exists. A +complete recoverable generation requires both of the following to exist locally: + +- a paid `l402.token` +- a concrete static address bound to that token + +Pending tokens are not backed up. + +If a valid immutable backup for the current paid token ID and creation time +already exists, backup creation is a no-op. A corrupt or undecryptable file with +the same token ID in its name does not suppress creation of a valid backup. + +## Startup Behavior + +Startup is responsible for materializing the current generation before the +backup is written. + +On startup `loopd`: + +1. creates the recovery service +2. if the install is fresh, attempts to restore the latest selectable backup + from the active network directory +3. if nothing was restored, asks the static-address manager for the current + static address +4. if the address does not exist yet, fetches the paid L402, derives the client + key, requests the static address from the server, imports the tapscript into + lnd, and stores the static-address row +5. writes the immutable backup for the resulting paid-L402/static-address + generation + +This gives recovery the "one backup per L402" property without later backup +refreshes. + +### Existing Users + +For existing users that already have a paid L402 and a concrete static address, +the first startup with the upgraded client backfills the missing immutable +backup for the active generation. + +### Fresh Installs + +For fresh installations, startup first checks whether a selectable immutable +backup exists in the active Loop data directory. + +If one is selected and passes full restore validation, Loop restores it instead +of creating a new paid L402 generation. + +If no backup is restored, startup materializes the initial paid L402 plus +concrete static address so the backup can be written immediately. + +The `loop static new` command is therefore no longer the only creation point. +It returns the current static address and only falls back to on-demand creation +if startup initialization did not complete earlier. + +## Restore Flow + +`loop recover --backup_file ` restores a specific immutable backup. If +`--backup_file` is omitted, Loop uses the same active-directory selection logic +described above and restores the selected backup after full validation. + +Restore performs the following steps: + +1. derive the local encryption key from lnd +2. resolve the explicit backup path or select a backup from the active network + directory +3. read, decrypt, and unmarshal the backup file +4. validate the backup version, Bitcoin network, filename metadata, paid-token + metadata, and required static-address fields +5. reconstruct the concrete static-address parameters and find the matching + client key in lnd before writing token files +6. restore the paid `l402.token` file if it is absent, or verify that an + existing file has identical contents +7. import the tapscript into lnd and create or reuse the local concrete + static-address record +8. if static-address restore fails after token files were written, remove only + the token files written by this restore attempt +9. trigger best-effort deposit reconciliation + +Client-key reconstruction uses the following strategy: + +- scan child indexes `0` through `20` in the legacy static-address client key + family using `DeriveKey` +- accept the child whose derived pubkey matches the backed-up client pubkey + +The multi-address scan-and-rebuild flow is not active yet. The immutable backup +already contains the address-space metadata that flow will need. + +## Future Multi-Address Generation + +The planned multi-address model uses two dedicated client-side key families: + +- `swap.StaticMultiAddressKeyFamily` for externally visible static-address + deposits +- `swap.StaticAddressChangeKeyFamily` for outputs that return value back into + the static-address address space + +The legacy `swap.StaticAddressKeyFamily` remains the V0 concrete static-address +family and the static-address HTLC key family. + +The future `static_addresses` table remains a table of concrete derived +addresses. Each row represents one address child and stores: + +- the client pubkey +- the server pubkey +- the client key family +- the client key index +- the resulting `pkScript` +- the protocol version +- the address initiation height + +The immutable backup does not store every row. Instead it stores the +address-space metadata that allows those rows to be rediscovered by scanning. + +For each future receive or change address: + +1. the client chooses the appropriate key family +2. the client derives the next pubkey from lnd for that family +3. the client combines that pubkey with the L402-bound server pubkey using the + static-address MuSig2 construction for the backed-up protocol version +4. the taproot tweak commits to the static-address timeout leaf +5. the resulting taproot output key yields the final P2TR `pkScript` +6. the concrete child row is stored locally in `static_addresses` + +The client key used in the MuSig2 aggregate key should also be the client's key +in the timeout path for that concrete multi-address output. + +Because the backup is immutable, future restore must regenerate candidate +receive and change children from the backed-up key families and the restored +lnd key material, rescan from the backed-up multi-address first height, and +rebuild local table rows from what is found on chain. The lookahead/gap limit +used during that scan is a restore parameter, not immutable backup data. Restore +must not depend on a mutable "last issued child index" snapshot. + +## Server Proof For Multi-Address Inputs + +For a future static swap or withdrawal that spends multi-address inputs, the +server-side proof model is: + +1. the paid L402 authenticates the request and identifies the generation +2. the L402 selects the fixed generation server pubkey and the fixed + protocol/expiry parameters +3. for each input, the client sends the concrete client pubkey that was used to + construct that input's address +4. the server recomputes the timeout leaf for the backed-up protocol version + and expiry +5. the server recomputes the MuSig2 aggregate key from the concrete client + pubkey for that input, the server pubkey bound to the L402 generation, and + the taproot tweak implied by the timeout leaf +6. the server derives the expected taproot output key and the expected P2TR + `pkScript` +7. the server compares that derived `pkScript` with the prevout `pkScript` of + the input being authorized + +If they match, the input belongs to that L402 generation because the output +commits to the generation's server key and the concrete client pubkey used for +that input. + +This proof is about generation membership, not about proving a particular child +index to the server. The immutable backup therefore only needs the stable +address-space metadata, while exact row discovery remains a client-side wallet +and chain-scan problem. + +## Operational Limits + +Restore in this implementation recreates the V0 one-address model only. + +Some practical consequences follow from that: + +- restoring an older immutable backup is best done into a fresh Loop data + directory, or into a directory that already contains the same token and + static-address row +- only one concrete static address can be recreated directly by this restore + code +- conflicting local `l402.token` contents or a different existing + static-address row cause restore to fail rather than overwrite local state +- active deposits are rebuilt best-effort from wallet reconciliation, not by + replaying every stored deposit transition + +## Why The Backup Is Immutable + +The multi-address work needs recovery to be based on stable root material, not +on mutable local cursor snapshots. + +Using one immutable backup per L402 enforces that discipline: + +- the backup must describe a recoverable generation root +- restore must be able to rediscover state from deterministic wallet- and + chain-derived scanning +- later address issuance must not depend on backup files being rewritten + +That discipline keeps later address issuance independent from backup-file +rewrites. + +## Package Boundaries + +This package owns: + +- backup payload definition +- backup encryption and decryption +- immutable backup-file discovery and selection +- paid L402 token-file backup and restore +- V0 static-address key re-derivation and restore orchestration +- static-address metadata fields for future multi-address restore +- post-restore deposit reconciliation orchestration + +This package does not own: + +- CLI command handling +- gRPC transport +- the static-address server protocol +- the future multi-address scanning implementation +- `loopd` startup wiring diff --git a/recovery/service.go b/recovery/service.go new file mode 100644 index 000000000..b02410561 --- /dev/null +++ b/recovery/service.go @@ -0,0 +1,1264 @@ +package recovery + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + staticaddrversion "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/lntypes" + "golang.org/x/crypto/nacl/secretbox" + "gopkg.in/macaroon.v2" +) + +const ( + backupVersion = 1 + + backupBaseName = "L402_backup" + + backupFileExt = ".enc" + + // backupKeyScanLimit is the highest legacy client-family child index + // scanned when reconstructing the static-address client key. + backupKeyScanLimit = 20 + + paidTokenFileName = "l402.token" + + pendingTokenFileName = "l402.token.pending" +) + +// backupKeyLocator identifies the lnd key used only for deriving the local +// backup encryption key. The encrypted backup stays tied to the same lnd seed +// material without adding a separate user-managed password. +var backupKeyLocator = keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 0, +} + +// backupMagic prefixes encrypted backup files so corrupt or unrelated files can +// be rejected before attempting to unmarshal JSON payloads. +var backupMagic = []byte("loopbak1") + +// StaticAddressManager is the subset of static-address behavior required for +// creating and restoring recovery backups. +type StaticAddressManager interface { + // GetStaticAddressParameters returns the concrete legacy static address + // row that is paired with the current paid L402 generation. + GetStaticAddressParameters(context.Context) (*address.Parameters, error) + + // RestoreAddress recreates that concrete address row and imports its + // tapscript into lnd. The bool reports whether local static-address state + // changed, allowing restore responses to stay idempotent. + RestoreAddress(context.Context, + *address.Parameters) (*btcutil.AddressTaproot, bool, error) + + // CurrentHeight returns the manager's current chain height, which is + // stored as the future multi-address scan floor for this generation. + CurrentHeight() int32 +} + +// DepositManager is the subset of deposit-manager behavior required to +// reconcile deposits after restore. +type DepositManager interface { + // ReconcileDeposits asks lnd for wallet-visible static-address UTXOs and + // rebuilds deposit FSM state for anything not already tracked. + ReconcileDeposits(context.Context) (int, error) +} + +// RecoverResult describes the outcome of a restore attempt. +type RecoverResult struct { + // BackupFile is the encrypted backup file that was selected and + // restored. + BackupFile string + + // StaticAddress is the taproot address reconstructed from the backup. + StaticAddress string + + // RestoredStaticAddress reports whether restore created new local + // static-address state. It is false for an idempotent restore where the + // same address was already present. + RestoredStaticAddress bool + + // RestoredL402 reports whether restore wrote paid L402 token material. + // It is false when the same paid token already existed locally. + RestoredL402 bool + + // NumDepositsFound is the number of wallet-visible static-address + // deposits discovered by the post-restore reconciliation pass. + NumDepositsFound int + + // DepositReconciliationError contains a non-fatal reconciliation error. + // Token and address restore can still succeed even if deposit discovery + // needs to be retried later. + DepositReconciliationError string +} + +// Service coordinates creation and restoration of encrypted local recovery +// backups for Loop static-address and L402 state. +type Service struct { + dataDir string + network string + signer lndclient.SignerClient + walletKit lndclient.WalletKitClient + staticAddressManager StaticAddressManager + depositManager DepositManager +} + +type backupPayload struct { + Version uint32 `json:"version"` + Network string `json:"network"` + L402TokenID string `json:"l402_token_id"` + L402TokenCreatedAt int64 `json:"l402_token_created_at"` + StaticAddress *staticAddressBackup `json:"static_address,omitempty"` + TokenFiles []*l402TokenFileEntry `json:"token_files,omitempty"` +} + +// staticAddressBackup contains the legacy single-address data that can be +// restored directly today, plus the stable per-L402 multi-address/change branch +// fields future multi-address recovery will scan from. +type staticAddressBackup struct { + ProtocolVersion uint32 `json:"protocol_version"` + ClientPubKey []byte `json:"client_pubkey,omitempty"` + ServerPubKey []byte `json:"server_pubkey"` + Expiry uint32 `json:"expiry"` + LegacyClientKeyFamily int32 `json:"legacy_client_key_family,omitempty"` + MainKeyFamily int32 `json:"main_key_family"` + ChangeKeyFamily int32 `json:"change_key_family"` + LegacyFirstHeight int32 `json:"legacy_first_height,omitempty"` + MultiAddressFirstHeight int32 `json:"multi_address_first_height,omitempty"` +} + +type l402TokenFileEntry struct { + Name string `json:"name"` + Data []byte `json:"data"` +} + +type currentTokenState struct { + TokenID string + TokenCreatedAt int64 + TokenFiles []*l402TokenFileEntry +} + +type tokenRestoreResult struct { + restored bool + writtenPaths []string +} + +type paidTokenMetadata struct { + tokenID string + tokenCreatedAt int64 +} + +type backupFileDetails struct { + tokenID string + titleTimestamp int64 +} + +// NewService constructs a recovery service for a specific loop network data +// directory. +func NewService(dataDir, network string, signer lndclient.SignerClient, + walletKit lndclient.WalletKitClient, + staticAddressManager StaticAddressManager, + depositManager DepositManager) *Service { + + return &Service{ + dataDir: dataDir, + network: network, + signer: signer, + walletKit: walletKit, + staticAddressManager: staticAddressManager, + depositManager: depositManager, + } +} + +// WriteBackup writes an encrypted backup file for the current paid-L402 / +// static-address generation. It returns an empty path when there is no +// complete recoverable generation yet, or when the current L402 already has an +// immutable backup on disk. +func (s *Service) WriteBackup(ctx context.Context) (string, error) { + // A backup is immutable and generation-based, so first collect enough + // state to prove the current generation is complete: one paid L402 token + // plus one concrete static address bound to that token. + payload, hasState, err := s.buildPayload(ctx) + if err != nil || !hasState { + return "", err + } + + // We need the derived key before checking for existing backups because a + // filename match alone is not enough. A stale or corrupt file with the same + // token ID must not suppress writing a valid backup. + key, err := s.deriveEncryptionKey(ctx) + if err != nil { + return "", err + } + + // If a valid backup for the exact token creation time already exists, the + // generation is already protected and must not be rewritten. + if backupFile, err := findValidBackupFileForToken( + s.dataDir, key, s.network, payload.L402TokenID, + payload.L402TokenCreatedAt, + ); err != nil { + return "", err + } else if backupFile != "" { + return "", nil + } + + fileName := backupFilePath( + s.dataDir, payload.L402TokenID, payload.L402TokenCreatedAt, + ) + + // The plaintext is never written to disk. It is marshaled in memory, + // encrypted with the lnd-derived key, then atomically installed. + plaintext, err := json.Marshal(payload) + if err != nil { + return "", err + } + + encrypted, err := encryptBackupPayload(key, plaintext) + if err != nil { + return "", err + } + + err = writeFileAtomically(fileName, encrypted) + if err != nil { + return "", err + } + + return fileName, nil +} + +// RestoreLatestOnFreshInstall restores the most recent local backup only when +// loopd has no local token files or static-address state yet. It returns the +// restore result together with a boolean indicating whether a restore was +// actually performed. +func (s *Service) RestoreLatestOnFreshInstall(ctx context.Context) ( + *RecoverResult, bool, error) { + + // Automatic startup restore is intentionally limited to a truly fresh Loop + // directory so it never overwrites an existing token or static address. + freshInstall, err := s.isFreshInstall(ctx) + if err != nil { + return nil, false, err + } + if !freshInstall { + return nil, false, nil + } + + result, err := s.Restore(ctx, "") + switch { + case err == nil: + return result, true, nil + + case errors.Is(err, os.ErrNotExist): + // A fresh install without backup files should continue normal startup + // initialization and create a new paid-L402/static-address generation. + return nil, false, nil + + default: + return nil, false, err + } +} + +// Restore restores the local static-address and L402 state from an encrypted +// backup file. If backupFile is empty, the most recent immutable generation +// backup in the active network directory is used. +func (s *Service) Restore(ctx context.Context, backupFile string) ( + *RecoverResult, error) { + + // Restores use the same lnd-derived key as backup creation. This makes the + // backup useful only with the original lnd seed material. + key, err := s.deriveEncryptionKey(ctx) + if err != nil { + return nil, err + } + + // An explicit path is validated for backup filename shape. An empty path + // means "scan the active network directory and pick the latest candidate". + fileName, err := s.resolveBackupFile(key, backupFile) + if err != nil { + return nil, err + } + + // Decrypt and validate the complete backup before touching local token or + // static-address state. + encrypted, err := os.ReadFile(fileName) + if err != nil { + return nil, err + } + + plaintext, err := decryptBackupPayload(key, encrypted) + if err != nil { + return nil, err + } + + var payload backupPayload + err = json.Unmarshal(plaintext, &payload) + if err != nil { + return nil, err + } + + err = payload.validateNetwork(s.network) + if err != nil { + return nil, err + } + + fileDetails, _ := parseBackupFileName(filepath.Base(fileName)) + err = payload.validateRecoverableGeneration(fileDetails) + if err != nil { + return nil, err + } + + result := &RecoverResult{ + BackupFile: fileName, + } + + var restoreParams *address.Parameters + if payload.StaticAddress != nil { + // Reconstruct and validate the concrete static-address parameters up + // front. If the backed-up key cannot be derived from lnd, no token file + // is written. + restoreParams, err = s.prepareStaticAddressRestore( + ctx, payload.StaticAddress, + ) + if err != nil { + return nil, err + } + } + + tokenRestore, err := s.restoreTokenFiles(payload.TokenFiles) + if err != nil { + return nil, err + } + result.RestoredL402 = tokenRestore.restored + + if restoreParams != nil { + addr, restored, err := s.restorePreparedStaticAddress( + ctx, restoreParams, + ) + if err != nil { + // Token files are restored before the address so the local L402 is + // available to code that validates the generation. If the address + // restore fails, remove only files written by this restore attempt. + rollbackErr := cleanupRestoredTokenFiles( + tokenRestore.writtenPaths, + ) + if rollbackErr != nil { + return nil, fmt.Errorf("unable to restore static "+ + "address: %w (also failed to roll back "+ + "restored token files: %v)", err, + rollbackErr) + } + + return nil, err + } + + result.StaticAddress = addr + result.RestoredStaticAddress = restored + } + + if payload.StaticAddress != nil && s.depositManager != nil { + // Deposit history is not serialized in the backup. After the address + // is restored, reconciliation asks lnd for current UTXOs and recreates + // missing deposit FSMs best-effort. + numDeposits, err := s.depositManager.ReconcileDeposits(ctx) + if err != nil { + result.DepositReconciliationError = err.Error() + } else { + result.NumDepositsFound = numDeposits + } + } + + return result, nil +} + +func (p *backupPayload) validateNetwork(currentNetwork string) error { + // These checks validate the envelope-level metadata before any generation + // contents are trusted. + switch { + case p.Version != backupVersion: + return fmt.Errorf("unsupported backup version %d", p.Version) + + case p.Network == "": + return fmt.Errorf("backup file is missing a network") + + case p.L402TokenID == "": + return fmt.Errorf("backup file is missing an L402 token ID") + + case p.Network != currentNetwork: + return fmt.Errorf("backup file network %s does not match "+ + "daemon network %s", p.Network, currentNetwork) + } + + return nil +} + +func (p *backupPayload) validateRecoverableGeneration( + fileDetails *backupFileDetails) error { + + // When the caller knows the filename metadata, require it to match the + // payload. This keeps the immutable filename and encrypted contents bound + // to the same L402 generation. + if fileDetails != nil { + if p.L402TokenID != fileDetails.tokenID { + return fmt.Errorf("backup file token ID %s does not match "+ + "payload token ID %s", fileDetails.tokenID, + p.L402TokenID) + } + + if p.L402TokenCreatedAt != fileDetails.titleTimestamp { + return fmt.Errorf("backup file timestamp %d does not "+ + "match payload L402 creation time %d", + fileDetails.titleTimestamp, p.L402TokenCreatedAt) + } + } + + if len(p.TokenFiles) == 0 { + return fmt.Errorf("backup file is missing paid L402 token data") + } + + if p.StaticAddress == nil { + return fmt.Errorf("backup file is missing static address " + + "parameters") + } + + // The raw token file is the source of truth for the paid L402. Decode its + // metadata and make sure it matches the generation named by the payload. + metadata, err := validatePaidTokenFiles(p.TokenFiles) + if err != nil { + return err + } + + if metadata.tokenID != p.L402TokenID { + return fmt.Errorf("backup L402 token ID %s does not match "+ + "payload token ID %s", metadata.tokenID, p.L402TokenID) + } + + if metadata.tokenCreatedAt != p.L402TokenCreatedAt { + return fmt.Errorf("backup L402 token creation time %d does "+ + "not match payload creation time %d", + metadata.tokenCreatedAt, p.L402TokenCreatedAt) + } + + return nil +} + +func (s *Service) buildPayload(ctx context.Context) (*backupPayload, bool, + error) { + + // Backups are only meaningful after the token payment completed. Pending + // L402 tokens can still change and do not define an immutable generation. + tokenState, err := s.currentPaidToken() + if err != nil { + return nil, false, err + } + if tokenState == nil || s.staticAddressManager == nil { + return nil, false, nil + } + + payload := &backupPayload{ + Version: backupVersion, + Network: s.network, + L402TokenID: tokenState.TokenID, + L402TokenCreatedAt: tokenState.TokenCreatedAt, + TokenFiles: tokenState.TokenFiles, + } + + // The current static-address row supplies the legacy concrete address that + // this implementation can restore today. The same payload also stores the + // deterministic families and scan floor future multi-address recovery will + // use without rewriting this backup. + addrParams, err := s.staticAddressManager.GetStaticAddressParameters(ctx) + switch { + case err == nil: + multiAddressFirstHeight := s.staticAddressManager.CurrentHeight() + if multiAddressFirstHeight <= 0 { + return nil, false, fmt.Errorf( + "invalid multi-address first height %d", + multiAddressFirstHeight, + ) + } + + payload.StaticAddress = &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey. + SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey. + SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: int32( + addrParams.KeyLocator.Family, + ), + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: multiAddressFirstHeight, + } + + case errors.Is(err, address.ErrNoStaticAddress): + // The current L402 does not have a complete static-address + // generation yet, so there is nothing immutable to back up. + return nil, false, nil + + default: + return nil, false, err + } + + hasState := payload.StaticAddress != nil && len(payload.TokenFiles) > 0 + + return payload, hasState, nil +} + +func (s *Service) currentPaidToken() (*currentTokenState, error) { + tokenStore, err := l402.NewFileStore(s.dataDir) + if err != nil { + return nil, err + } + + token, err := tokenStore.CurrentToken() + switch { + case err == nil: + + case errors.Is(err, l402.ErrNoToken): + return nil, nil + + default: + return nil, err + } + + // Only fully paid tokens define an immutable recoverable generation. + if token.Preimage == (lntypes.Preimage{}) { + return nil, nil + } + + // Preserve the exact token file bytes instead of reserializing the token. + // That keeps restore compatible with Aperture's token-store format. + tokenID, err := decodeTokenID(token) + if err != nil { + return nil, err + } + + path := filepath.Join(s.dataDir, paidTokenFileName) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, err + } + + return ¤tTokenState{ + TokenID: tokenID, + TokenCreatedAt: token.TimeCreated.UnixNano(), + TokenFiles: []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: data, + }}, + }, nil +} + +func decodeTokenID(token *l402.Token) (string, error) { + identifier, err := l402.DecodeIdentifier( + bytes.NewReader(token.BaseMacaroon().Id()), + ) + if err != nil { + return "", err + } + + return identifier.TokenID.String(), nil +} + +func (s *Service) readTokenFiles() ([]*l402TokenFileEntry, error) { + var files []*l402TokenFileEntry + for _, name := range []string{paidTokenFileName, pendingTokenFileName} { + path := filepath.Join(s.dataDir, name) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + + return nil, err + } + + files = append(files, &l402TokenFileEntry{ + Name: name, + Data: data, + }) + } + + return files, nil +} + +func (s *Service) resolveBackupFile(key [32]byte, backupFile string) (string, + error) { + + if backupFile != "" { + // Explicit restore still requires the immutable backup filename format + // so payload validation can compare encrypted contents to the title. + return validateBackupFilePath(backupFile) + } + + return latestBackupFilePath(s.dataDir, key, s.network) +} + +func validateBackupFilePath(path string) (string, error) { + cleanPath := lncfg.CleanAndExpandPath(path) + if _, ok := parseBackupFileName(filepath.Base(cleanPath)); !ok { + return "", fmt.Errorf("invalid backup file path %q", path) + } + + return cleanPath, nil +} + +type backupSelection struct { + fileName string + tokenID string + sortTimestamp int64 +} + +func latestBackupFilePath(dataDir string, key [32]byte, + network string) (string, error) { + + dirEntries, err := os.ReadDir(dataDir) + if err != nil { + return "", err + } + + var ( + latestSelection *backupSelection + firstErr error + ) + + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + + // Only files using the immutable backup name shape participate in + // automatic selection; everything else in the data dir is ignored. + details, ok := parseBackupFileName(entry.Name()) + if !ok { + continue + } + + path := filepath.Join(dataDir, entry.Name()) + payload, err := readBackupPayload(key, path) + if err != nil { + // Keep scanning so one corrupt or wrong-key backup does not hide a + // valid older backup. + if firstErr == nil { + firstErr = err + } + continue + } + err = payload.validateNetwork(network) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + err = payload.validateRecoverableGeneration(details) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + + selection := &backupSelection{ + fileName: path, + tokenID: details.tokenID, + sortTimestamp: details.titleTimestamp, + } + + if latestSelection == nil || + selection.sortTimestamp > latestSelection.sortTimestamp || + (selection.sortTimestamp == latestSelection.sortTimestamp && + selection.tokenID > latestSelection.tokenID) { + + latestSelection = selection + } + } + + if latestSelection != nil { + return latestSelection.fileName, nil + } + if firstErr != nil { + return "", firstErr + } + + return "", os.ErrNotExist +} + +func backupFilePath(dataDir, tokenID string, tokenCreatedAt int64) string { + return filepath.Join(dataDir, backupFileName(tokenID, tokenCreatedAt)) +} + +func backupFileName(tokenID string, tokenCreatedAt int64) string { + return fmt.Sprintf( + "%s_%019d_%s%s", backupBaseName, tokenCreatedAt, tokenID, + backupFileExt, + ) +} + +func backupFileTokenID(name string) (string, bool) { + details, ok := parseBackupFileName(name) + if !ok { + return "", false + } + + return details.tokenID, true +} + +func parseBackupFileName(name string) (*backupFileDetails, bool) { + if !strings.HasPrefix(name, backupBaseName+"_") || + !strings.HasSuffix(name, backupFileExt) { + + return nil, false + } + + remainder := strings.TrimSuffix( + strings.TrimPrefix(name, backupBaseName+"_"), backupFileExt, + ) + + parts := strings.SplitN(remainder, "_", 2) + if len(parts) != 2 { + return nil, false + } + + titleTimestamp, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return nil, false + } + tokenID := parts[1] + + _, err = l402.MakeIDFromString(tokenID) + if err != nil { + return nil, false + } + + return &backupFileDetails{ + tokenID: tokenID, + titleTimestamp: titleTimestamp, + }, true +} + +func findValidBackupFileForToken(dataDir string, key [32]byte, network, + tokenID string, tokenCreatedAt int64) (string, error) { + + dirEntries, err := os.ReadDir(dataDir) + if err != nil { + return "", err + } + + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + + // Search by token ID first, then decrypt to verify the candidate is a + // valid backup for this exact paid-token generation. + details, ok := parseBackupFileName(entry.Name()) + if !ok || details.tokenID != tokenID { + continue + } + + path := filepath.Join(dataDir, entry.Name()) + payload, err := readBackupPayload(key, path) + if err != nil { + // Invalid same-token files are ignored so WriteBackup can replace a + // corrupt placeholder with a real backup. + continue + } + + err = payload.validateNetwork(network) + if err != nil { + continue + } + + err = payload.validateRecoverableGeneration(details) + if err != nil { + continue + } + + if payload.L402TokenCreatedAt != tokenCreatedAt { + continue + } + + return path, nil + } + + return "", nil +} + +func readBackupPlaintext(key [32]byte, path string) ([]byte, error) { + ciphertext, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + return decryptBackupPayload(key, ciphertext) +} + +func readBackupPayload(key [32]byte, path string) (*backupPayload, error) { + plaintext, err := readBackupPlaintext(key, path) + if err != nil { + return nil, err + } + + var payload backupPayload + err = json.Unmarshal(plaintext, &payload) + if err != nil { + return nil, err + } + + return &payload, nil +} + +func (s *Service) prepareStaticAddressRestore(ctx context.Context, + backup *staticAddressBackup) (*address.Parameters, error) { + + // This phase only validates and reconstructs parameters. The token store + // and static-address DB are not modified until all backup fields prove + // internally consistent and derivable from lnd. + if s.staticAddressManager == nil { + return nil, fmt.Errorf("static address restore is unavailable") + } + + if !staticaddrversion.AddressProtocolVersion( + backup.ProtocolVersion, + ).Valid() { + + return nil, fmt.Errorf("invalid static address protocol version %d", + backup.ProtocolVersion) + } + + serverPubKey, err := btcec.ParsePubKey(backup.ServerPubKey) + if err != nil { + return nil, err + } + + clientPubKey, locator, err := s.resolveClientKey(ctx, backup) + if err != nil { + return nil, err + } + + // PkScript is intentionally omitted here. RestoreAddress derives it again + // from the reconstructed script and rejects mismatches when one is supplied. + return &address.Parameters{ + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + Expiry: backup.Expiry, + KeyLocator: locator, + ProtocolVersion: staticaddrversion.AddressProtocolVersion( + backup.ProtocolVersion, + ), + InitiationHeight: backup.LegacyFirstHeight, + }, nil +} + +func (s *Service) restoreTokenFiles( + backupFiles []*l402TokenFileEntry) (*tokenRestoreResult, error) { + + if len(backupFiles) == 0 { + return &tokenRestoreResult{}, nil + } + + existingFiles, err := s.readTokenFiles() + if err != nil { + return nil, err + } + + existingByName := make(map[string][]byte, len(existingFiles)) + for _, file := range existingFiles { + existingByName[file.Name] = file.Data + } + + // Accept only the expected token-store file. The backup format must not be + // able to write arbitrary names into the Loop data directory. + backupByName := make(map[string][]byte, len(backupFiles)) + for _, file := range backupFiles { + if !isTokenFileName(file.Name) { + return nil, fmt.Errorf("unexpected token file name %q", + file.Name) + } + + backupByName[file.Name] = file.Data + } + + for name := range existingByName { + if _, ok := backupByName[name]; ok { + continue + } + + return nil, fmt.Errorf("token store already contains "+ + "unexpected file %q", name) + } + + result := &tokenRestoreResult{} + for name, data := range backupByName { + path := filepath.Join(s.dataDir, name) + if current, ok := existingByName[name]; ok { + // Restoring the same generation is idempotent, but conflicting + // local token bytes are never overwritten. + if !bytes.Equal(current, data) { + return nil, fmt.Errorf("token file %q already exists "+ + "with different contents", name) + } + + continue + } + + err := writeFileAtomically(path, data) + if err != nil { + return nil, err + } + result.restored = true + result.writtenPaths = append(result.writtenPaths, path) + } + + return result, nil +} + +func validatePaidTokenFiles( + backupFiles []*l402TokenFileEntry) (*paidTokenMetadata, error) { + + // Decode the backed-up token file enough to prove it is a paid L402 and to + // bind its ID/creation time to the rest of the payload. + var paidTokenData []byte + for _, file := range backupFiles { + if !isTokenFileName(file.Name) { + return nil, fmt.Errorf("unexpected token file name %q", + file.Name) + } + + if paidTokenData != nil { + return nil, fmt.Errorf("backup contains duplicate paid " + + "L402 token data") + } + + paidTokenData = file.Data + } + + if paidTokenData == nil { + return nil, fmt.Errorf("backup file is missing paid L402 token data") + } + + return parsePaidTokenMetadata(paidTokenData) +} + +func parsePaidTokenMetadata(data []byte) (*paidTokenMetadata, error) { + r := bytes.NewReader(data) + + var macLen uint32 + err := binary.Read(r, binary.BigEndian, &macLen) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token macaroon "+ + "length: %w", err) + } + + if uint64(macLen) > uint64(r.Len()) { + return nil, fmt.Errorf("invalid L402 token macaroon length") + } + + macBytes := make([]byte, macLen) + err = binary.Read(r, binary.BigEndian, &macBytes) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token macaroon: %w", + err) + } + + var paymentHash lntypes.Hash + err = binary.Read(r, binary.BigEndian, &paymentHash) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token payment hash: %w", + err) + } + + var preimage lntypes.Preimage + err = binary.Read(r, binary.BigEndian, &preimage) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token preimage: %w", + err) + } + + if preimage == (lntypes.Preimage{}) { + return nil, fmt.Errorf("backup L402 token is not paid") + } + + var amountPaid uint64 + err = binary.Read(r, binary.BigEndian, &amountPaid) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token amount: %w", + err) + } + + var routingFeePaid uint64 + err = binary.Read(r, binary.BigEndian, &routingFeePaid) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token routing fee: %w", + err) + } + + var tokenCreatedAt int64 + err = binary.Read(r, binary.BigEndian, &tokenCreatedAt) + if err != nil { + return nil, fmt.Errorf("unable to read L402 token creation time: %w", + err) + } + + mac := &macaroon.Macaroon{} + err = mac.UnmarshalBinary(macBytes) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal L402 token "+ + "macaroon: %w", err) + } + + identifier, err := l402.DecodeIdentifier(bytes.NewReader(mac.Id())) + if err != nil { + return nil, fmt.Errorf("unable to decode L402 token ID: %w", err) + } + + return &paidTokenMetadata{ + tokenID: identifier.TokenID.String(), + tokenCreatedAt: tokenCreatedAt, + }, nil +} + +func (s *Service) restorePreparedStaticAddress(ctx context.Context, + params *address.Parameters) (string, bool, error) { + + // The address manager owns persistence and lnd tapscript import ordering. + // Recovery only supplies already-validated parameters. + addr, restored, err := s.staticAddressManager.RestoreAddress( + ctx, params, + ) + if err != nil { + return "", false, err + } + + return addr.String(), restored, nil +} + +func cleanupRestoredTokenFiles(paths []string) error { + if len(paths) == 0 { + return nil + } + + // Only remove files created by this restore attempt. Pre-existing matching + // files are never included in paths and are therefore left untouched. + var cleanupErrs []error + for _, path := range paths { + err := os.Remove(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + cleanupErrs = append( + cleanupErrs, fmt.Errorf("remove %s: %w", path, err), + ) + } + } + + return errors.Join(cleanupErrs...) +} + +func (s *Service) resolveClientKey(ctx context.Context, + backup *staticAddressBackup) ( + *btcec.PublicKey, keychain.KeyLocator, error) { + + if len(backup.ClientPubKey) == 0 { + return nil, keychain.KeyLocator{}, fmt.Errorf( + "backup file is missing the static address client pubkey", + ) + } + + if backup.LegacyClientKeyFamily == 0 { + return nil, keychain.KeyLocator{}, fmt.Errorf( + "backup file is missing the legacy static address " + + "client key family", + ) + } + + expectedClientPubKey, err := btcec.ParsePubKey(backup.ClientPubKey) + if err != nil { + return nil, keychain.KeyLocator{}, err + } + + // Older backups do not persist the key index. Scan the legacy static + // address family and accept the child whose pubkey matches the backup. + for idx := 0; idx <= backupKeyScanLimit; idx++ { + candidateLocator := keychain.KeyLocator{ + Family: keychain.KeyFamily(backup.LegacyClientKeyFamily), + Index: uint32(idx), + } + + candidateKey, err := s.walletKit.DeriveKey( + ctx, &candidateLocator, + ) + if err != nil { + continue + } + + if candidateKey.PubKey.IsEqual(expectedClientPubKey) { + return candidateKey.PubKey, candidateLocator, nil + } + } + + return nil, keychain.KeyLocator{}, fmt.Errorf("unable to derive " + + "static address client key from backup") +} + +func (s *Service) deriveEncryptionKey(ctx context.Context) ([32]byte, error) { + // DeriveSharedKey gives both backup and restore the same symmetric key on + // any lnd instance restored from the same seed/key material. + return s.signer.DeriveSharedKey( + ctx, lndclient.SharedKeyNUMS, &backupKeyLocator, + ) +} + +func encryptBackupPayload(key [32]byte, plaintext []byte) ([]byte, error) { + var nonce [24]byte + _, err := rand.Read(nonce[:]) + if err != nil { + return nil, err + } + + cipherText := secretbox.Seal(nil, plaintext, &nonce, &key) + encoded := make([]byte, 0, len(backupMagic)+len(nonce)+len(cipherText)) + encoded = append(encoded, backupMagic...) + encoded = append(encoded, nonce[:]...) + encoded = append(encoded, cipherText...) + + return encoded, nil +} + +func decryptBackupPayload(key [32]byte, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < len(backupMagic)+24 { + return nil, fmt.Errorf("backup file is too short") + } + if !bytes.Equal(ciphertext[:len(backupMagic)], backupMagic) { + return nil, fmt.Errorf("backup file has an unknown format") + } + + var nonce [24]byte + copy(nonce[:], ciphertext[len(backupMagic):len(backupMagic)+24]) + + plaintext, ok := secretbox.Open( + nil, ciphertext[len(backupMagic)+24:], &nonce, &key, + ) + if !ok { + return nil, fmt.Errorf("unable to decrypt backup file") + } + + return plaintext, nil +} + +func writeFileAtomically(path string, data []byte) error { + tempPath := path + ".tmp" + + // Write private files through a temp path so a crash cannot leave a + // partially written backup or token at the final name. + file, err := os.OpenFile( + tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600, + ) + if err != nil { + return err + } + + _, err = file.Write(data) + if err != nil { + _ = file.Close() + _ = os.Remove(tempPath) + + return err + } + + err = file.Sync() + if err != nil { + _ = file.Close() + _ = os.Remove(tempPath) + + return err + } + + err = file.Close() + if err != nil { + _ = os.Remove(tempPath) + + return err + } + + err = os.Rename(tempPath, path) + if err != nil { + _ = os.Remove(tempPath) + } + + return err +} + +func isTokenFileName(name string) bool { + return filepath.Base(name) == name && name == paidTokenFileName +} + +func (s *Service) isFreshInstall(ctx context.Context) (bool, error) { + // Any local token material means this is not safe for automatic restore: + // a paid token would be overwritten, and a pending token may still be in + // the middle of the L402 acquisition flow. + hasTokenFiles, err := hasAnyLocalTokenFiles(s.dataDir) + if err != nil || hasTokenFiles { + return !hasTokenFiles && err == nil, err + } + + if s.staticAddressManager == nil { + return true, nil + } + + // Static-address state also makes the install non-fresh, even if no token + // file exists, because it may represent a partial local generation. + _, err = s.staticAddressManager.GetStaticAddressParameters(ctx) + switch { + case err == nil: + return false, nil + + case errors.Is(err, address.ErrNoStaticAddress): + return true, nil + + default: + return false, err + } +} + +func hasAnyLocalTokenFiles(dataDir string) (bool, error) { + for _, name := range []string{paidTokenFileName, pendingTokenFileName} { + path := filepath.Join(dataDir, name) + _, err := os.Stat(path) + switch { + case err == nil: + return true, nil + + case errors.Is(err, os.ErrNotExist): + continue + + default: + return false, err + } + } + + return false, nil +} diff --git a/recovery/service_test.go b/recovery/service_test.go new file mode 100644 index 000000000..2a4d0f444 --- /dev/null +++ b/recovery/service_test.go @@ -0,0 +1,2331 @@ +package recovery + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/aperture/l402" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/staticaddr/address" + staticaddrscript "github.com/lightninglabs/loop/staticaddr/script" + staticaddrversion "github.com/lightninglabs/loop/staticaddr/version" + "github.com/lightninglabs/loop/swap" + testutils "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +// TestEncryptDecryptBackupPayload verifies that a recovery backup payload +// round-trips through the secretbox envelope and is not stored as plaintext. +func TestEncryptDecryptBackupPayload(t *testing.T) { + t.Parallel() + + var key [32]byte + copy(key[:], []byte("0123456789abcdefghijklmnopqrstuv")) + + plaintext := []byte("loop recovery backup payload") + + encrypted, err := encryptBackupPayload(key, plaintext) + require.NoError(t, err) + require.NotEqual(t, plaintext, encrypted) + + decrypted, err := decryptBackupPayload(key, encrypted) + require.NoError(t, err) + require.Equal(t, plaintext, decrypted) +} + +// TestBackupEncryptionUsesSignerDerivedKey verifies that backups are encrypted +// with the documented lnd-derived key and can only be restored with that same +// derived key. +func TestBackupEncryptionUsesSignerDerivedKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + restoreDir := t.TempDir() + lnd := testutils.NewMockLnd() + signer := &fixedKeySigner{ + key: testBackupKey(1), + } + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Len(t, signer.calls, 1) + require.True(t, signer.calls[0].pubKey.IsEqual(lndclient.SharedKeyNUMS)) + require.Equal(t, backupKeyLocator, *signer.calls[0].locator) + + _, err = readBackupPayload(testBackupKey(2), backupFile) + require.ErrorContains(t, err, "unable to decrypt backup file") + + payload, err := readBackupPayload(testBackupKey(1), backupFile) + require.NoError(t, err) + require.EqualValues(t, backupVersion, payload.Version) + + wrongKeySvc := NewService( + restoreDir, "testnet", &fixedKeySigner{key: testBackupKey(2)}, + lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + _, err = wrongKeySvc.Restore(context.Background(), backupFile) + require.ErrorContains(t, err, "unable to decrypt backup file") + + rightKeySvc := NewService( + restoreDir, "testnet", &fixedKeySigner{key: testBackupKey(1)}, + lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + result, err := rightKeySvc.Restore(context.Background(), backupFile) + require.NoError(t, err) + require.True(t, result.RestoredL402) + require.True(t, result.RestoredStaticAddress) +} + +// TestWriteBackupReturnsEmptyWithoutState verifies that no backup is written +// before Loop has both paid L402 state and static-address state. +func TestWriteBackupReturnsEmptyWithoutState(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupReturnsEmptyWithTokenOnly verifies that a paid L402 by itself +// does not define a complete static-address recovery generation. +func TestWriteBackupReturnsEmptyWithTokenOnly(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupReturnsEmptyWithPendingToken verifies that pending L402 token +// material is not backed up as an immutable recovery generation. +func TestWriteBackupReturnsEmptyWithPendingToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + writePendingToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, backupFile) + require.Empty(t, listBackupFiles(t, dir)) +} + +// TestWriteBackupIncludesStaticAddressAndPaidToken verifies that a complete +// generation backup contains the expected static-address parameters, exact paid +// L402 token bytes and private file permissions. +func TestWriteBackupIncludesStaticAddressAndPaidToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + currentHeight: 654, + } + + tokenCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 123, time.UTC, + ) + tokenID := writePaidToken(t, dir, 1, tokenCreatedAt) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal( + t, backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()), + backupFile, + ) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + originalToken, err := os.ReadFile(filepath.Join(dir, paidTokenFileName)) + require.NoError(t, err) + + require.EqualValues(t, backupVersion, payload.Version) + require.Equal(t, "testnet", payload.Network) + require.Equal(t, tokenID, payload.L402TokenID) + require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt) + require.NotNil(t, payload.StaticAddress) + require.EqualValues( + t, addrParams.ProtocolVersion, payload.StaticAddress.ProtocolVersion, + ) + require.Equal( + t, addrParams.ClientPubkey.SerializeCompressed(), + payload.StaticAddress.ClientPubKey, + ) + require.Equal( + t, addrParams.ServerPubkey.SerializeCompressed(), + payload.StaticAddress.ServerPubKey, + ) + require.Equal(t, addrParams.Expiry, payload.StaticAddress.Expiry) + require.Equal( + t, int32(addrParams.KeyLocator.Family), + payload.StaticAddress.LegacyClientKeyFamily, + ) + require.Equal( + t, swap.StaticMultiAddressKeyFamily, + payload.StaticAddress.MainKeyFamily, + ) + require.Equal( + t, swap.StaticAddressChangeKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.LegacyClientKeyFamily, + payload.StaticAddress.MainKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.LegacyClientKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.NotEqual( + t, payload.StaticAddress.MainKeyFamily, + payload.StaticAddress.ChangeKeyFamily, + ) + require.Equal( + t, addrParams.InitiationHeight, + payload.StaticAddress.LegacyFirstHeight, + ) + require.Equal( + t, int32(654), + payload.StaticAddress.MultiAddressFirstHeight, + ) + require.Len(t, payload.TokenFiles, 1) + require.Equal(t, paidTokenFileName, payload.TokenFiles[0].Name) + require.Equal(t, originalToken, payload.TokenFiles[0].Data) + + info, err := os.Stat(backupFile) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +// TestStaticAddressBackupReconstructsLegacyStaticAddress verifies that the +// backed-up legacy client key material reconstructs the original static address +// tapscript and taproot address. +func TestStaticAddressBackupReconstructsLegacyStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + require.NotNil(t, payload.StaticAddress) + + serverPubKey, err := btcec.ParsePubKey( + payload.StaticAddress.ServerPubKey, + ) + require.NoError(t, err) + + clientPubKey, _, err := svc.resolveClientKey(ctx, payload.StaticAddress) + require.NoError(t, err) + + reconstructed, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(payload.StaticAddress.Expiry), clientPubKey, serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := reconstructed.StaticAddressScript() + require.NoError(t, err) + require.Equal(t, addrParams.PkScript, pkScript) + + expectedAddr, err := taprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), lnd.ChainParams, + ) + require.NoError(t, err) + + reconstructedAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(reconstructed.TaprootKey), lnd.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), reconstructedAddr.String()) +} + +// TestStaticAddressBackupReconstructsChangeStaticAddress verifies that the +// backed-up change key family can reconstruct the change static address and +// that it is distinct from the legacy main static address. +func TestStaticAddressBackupReconstructsChangeStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + expectedChangeKey, err := lnd.WalletKit.DeriveKey( + ctx, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressChangeKeyFamily), + Index: 0, + }, + ) + require.NoError(t, err) + + expectedChangeStaticAddr, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(addrParams.Expiry), expectedChangeKey.PubKey, + addrParams.ServerPubkey, + ) + require.NoError(t, err) + + expectedChangePkScript, err := expectedChangeStaticAddr.StaticAddressScript() + require.NoError(t, err) + + expectedChangeAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(expectedChangeStaticAddr.TaprootKey), + lnd.ChainParams, + ) + require.NoError(t, err) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 123, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + backupFile, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + require.NotNil(t, payload.StaticAddress) + + serverPubKey, err := btcec.ParsePubKey( + payload.StaticAddress.ServerPubKey, + ) + require.NoError(t, err) + + changeKeyDesc, err := lnd.WalletKit.DeriveKey( + ctx, &keychain.KeyLocator{ + Family: keychain.KeyFamily( + payload.StaticAddress.ChangeKeyFamily, + ), + Index: 0, + }, + ) + require.NoError(t, err) + + reconstructed, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, + int64(payload.StaticAddress.Expiry), changeKeyDesc.PubKey, + serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := reconstructed.StaticAddressScript() + require.NoError(t, err) + require.Equal(t, expectedChangePkScript, pkScript) + + reconstructedAddr, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(reconstructed.TaprootKey), + lnd.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedChangeAddr.String(), reconstructedAddr.String()) + + legacyAddr, err := taprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), lnd.ChainParams, + ) + require.NoError(t, err) + require.NotEqual(t, legacyAddr.String(), reconstructedAddr.String()) +} + +// TestWriteBackupIsImmutablePerL402 verifies that an existing backup for the +// active L402 token prevents rewriting or creating another backup for the same +// generation. +func TestWriteBackupIsImmutablePerL402(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + tokenID := writePaidToken( + t, dir, 2, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + firstBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal( + t, + backupFilePath( + dir, tokenID, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC). + UnixNano(), + ), + firstBackup, + ) + + secondBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Empty(t, secondBackup) + require.Equal(t, []string{firstBackup}, listBackupFiles(t, dir)) +} + +// TestWriteBackupIgnoresInvalidSameTokenBackup verifies that a corrupt file with +// the active token ID in its name does not suppress creation of a valid backup. +func TestWriteBackupIgnoresInvalidSameTokenBackup(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + + tokenCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + tokenID := writePaidToken(t, dir, 3, tokenCreatedAt) + + backupPath := backupFilePath(dir, tokenID, tokenCreatedAt.UnixNano()) + err := os.WriteFile(backupPath, []byte("corrupt backup"), 0600) + require.NoError(t, err) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + writtenBackup, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + require.Equal(t, backupPath, writtenBackup) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupPath) + require.NoError(t, err) + require.Equal(t, tokenID, payload.L402TokenID) + require.Equal(t, tokenCreatedAt.UnixNano(), payload.L402TokenCreatedAt) +} + +// TestRestoreLatestBackupPrefersNewestGeneration verifies that restoring +// without an explicit path selects the newest valid backup generation. +func TestRestoreLatestBackupPrefersNewestGeneration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + writePaidToken( + t, backupDir, 0x20, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + firstBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + writePaidToken( + t, backupDir, 0x10, + time.Date(2026, time.April, 14, 9, 31, 1, 0, time.UTC), + ) + secondBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + copyFile(t, firstBackup, filepath.Join(restoreDir, filepath.Base(firstBackup))) + copyFile( + t, secondBackup, filepath.Join(restoreDir, filepath.Base(secondBackup)), + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, err := destSvc.Restore(ctx, "") + require.NoError(t, err) + require.Equal(t, filepath.Join( + restoreDir, filepath.Base(secondBackup), + ), result.BackupFile) + require.True(t, result.RestoredL402) + require.True(t, result.RestoredStaticAddress) +} + +// TestRestoreLatestOnFreshInstallUsesLatestTimestampInTitle verifies that +// startup recovery on a fresh install selects the newest timestamped backup +// filename. +func TestRestoreLatestOnFreshInstallUsesLatestTimestampInTitle(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, staticMgr, nil, + ) + + firstCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + writePaidToken(t, backupDir, 0x20, firstCreatedAt) + firstBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + secondCreatedAt := time.Date( + 2026, time.April, 14, 9, 31, 1, 0, time.UTC, + ) + writePaidToken(t, backupDir, 0x10, secondCreatedAt) + secondBackup, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + err = os.Remove(filepath.Join(backupDir, paidTokenFileName)) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + destSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, restored, err := destSvc.RestoreLatestOnFreshInstall(ctx) + require.NoError(t, err) + require.True(t, restored) + require.Equal(t, secondBackup, result.BackupFile) + + // Keep both variables referenced to make the intended ordering explicit. + require.NotEqual(t, firstBackup, secondBackup) +} + +// TestLatestBackupFilePathSelection verifies latest-backup selection across +// invalid candidates, empty valid sets and timestamp tie-breaks. +func TestLatestBackupFilePathSelection(t *testing.T) { + t.Parallel() + + t.Run("skips invalid newer backups", func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, dir, 0x01, + time.Date(2026, time.April, 14, 9, 30, 1, 0, + time.UTC), + ) + validPath, err := svc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + newer := time.Date( + 2026, time.April, 14, 10, 30, 1, 0, time.UTC, + ).UnixNano() + corruptID := testTokenID(0x02) + err = os.WriteFile( + backupFilePath(dir, corruptID, newer), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + mismatchNameID := testTokenID(0x03) + writeBackupPayload( + t, svc, dir, mismatchNameID, newer+1, + validBackupPayload(t, testTokenID(0x04), newer+1), + ) + + wrongNetworkID := testTokenID(0x05) + wrongNetworkPayload := validBackupPayload(t, wrongNetworkID, + newer+2) + wrongNetworkPayload.Network = "mainnet" + writeBackupPayload( + t, svc, dir, wrongNetworkID, newer+2, + wrongNetworkPayload, + ) + + incompleteID := testTokenID(0x06) + incompletePayload := validBackupPayload(t, incompleteID, + newer+3) + incompletePayload.TokenFiles = nil + writeBackupPayload( + t, svc, dir, incompleteID, newer+3, + incompletePayload, + ) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.Equal(t, validPath, latestFile) + }) + + t.Run("returns error without valid backup", func(t *testing.T) { + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + err = os.WriteFile( + backupFilePath(dir, testTokenID(0x01), 1), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + _, err = latestBackupFilePath(dir, key, "testnet") + require.ErrorContains(t, err, "backup file is too short") + }) + + t.Run("tie breaks by token id", func(t *testing.T) { + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + timestamp := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ).UnixNano() + lowerID := testTokenID(0x01) + higherID := testTokenID(0x02) + + lowerPath := writeBackupPayload( + t, svc, dir, lowerID, timestamp, + validBackupPayload(t, lowerID, timestamp), + ) + higherPath := writeBackupPayload( + t, svc, dir, higherID, timestamp, + validBackupPayload(t, higherID, timestamp), + ) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.NotEqual(t, lowerPath, higherPath) + require.Equal(t, higherPath, latestFile) + }) +} + +// TestRestoreStaticAddressAndPaidToken documents the unit-level recovery story: +// a paid L402 generation with static-address parameters is written to an +// encrypted backup, then restored into an empty Loop data directory using the +// same lnd-derived encryption key. The restore must recreate the static address +// with the exact backed-up key material, expiry, protocol version and initiation +// height, write back the exact L402 token bytes, and run deposit reconciliation +// so a recovered daemon can discover funds sent to that static address. The +// live itest should extend this story with confirmed deposits, loop-in change, +// explicit generation switching via loop recover, withdrawals and swaps against +// a running Loop server. +func TestRestoreStaticAddressAndPaidToken(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + originalToken, err := os.ReadFile(filepath.Join(backupDir, paidTokenFileName)) + require.NoError(t, err) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + depositMgr := &mockDepositManager{ + depositsFound: 3, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, depositMgr, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.Equal(t, backupFile, result.BackupFile) + require.True(t, result.RestoredStaticAddress) + require.True(t, result.RestoredL402) + require.Equal(t, 3, result.NumDepositsFound) + require.Empty(t, result.DepositReconciliationError) + + require.Len(t, destStaticMgr.restoreCalls, 1) + restoredParams := destStaticMgr.restoreCalls[0] + require.True(t, restoredParams.ClientPubkey.IsEqual(addrParams.ClientPubkey)) + require.True(t, restoredParams.ServerPubkey.IsEqual(addrParams.ServerPubkey)) + require.Equal(t, addrParams.Expiry, restoredParams.Expiry) + require.Equal(t, addrParams.PkScript, restoredParams.PkScript) + require.Equal(t, addrParams.KeyLocator, restoredParams.KeyLocator) + require.Equal( + t, addrParams.ProtocolVersion, restoredParams.ProtocolVersion, + ) + require.Equal( + t, addrParams.InitiationHeight, restoredParams.InitiationHeight, + ) + + require.Equal(t, 1, depositMgr.calls) + restoredToken, err := os.ReadFile( + filepath.Join(restoreDir, paidTokenFileName), + ) + require.NoError(t, err) + require.Equal(t, originalToken, restoredToken) + + expectedAddr, err := destStaticMgr.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + require.NoError(t, err) + require.Equal(t, expectedAddr.String(), result.StaticAddress) +} + +// TestRestoreReturnsDepositReconciliationError verifies that restore succeeds +// even when deposit reconciliation fails, while reporting the reconciliation +// error to the caller. +func TestRestoreReturnsDepositReconciliationError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + depositErr := errors.New("reconcile failed") + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, + &mockDepositManager{err: depositErr}, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.Equal(t, depositErr.Error(), result.DepositReconciliationError) + require.Equal(t, 0, result.NumDepositsFound) +} + +// TestRestoreReportsNoStaticAddressChangeForIdempotentRestore verifies that +// restoring the already-present generation reports no L402 or static-address +// change while still returning the recovered address. +func TestRestoreReportsNoStaticAddressChangeForIdempotentRestore(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + sourceStaticMgr, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + writePaidToken( + t, restoreDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + restoreChanged: false, + restoreChangedSet: true, + } + destSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + result, err := destSvc.Restore(ctx, backupFile) + require.NoError(t, err) + require.False(t, result.RestoredL402) + require.False(t, result.RestoredStaticAddress) + require.NotEmpty(t, result.StaticAddress) + require.Len(t, destStaticMgr.restoreCalls, 1) +} + +// TestRestoreLatestOnFreshInstallSkipsNonFreshInstall verifies that startup +// auto-restore is skipped whenever local token or static-address state already +// makes the data directory non-fresh, even if a valid backup is present. +func TestRestoreLatestOnFreshInstallSkipsNonFreshInstall(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T, string, *testutils.LndMockServices) StaticAddressManager + }{ + { + name: "local paid token", + setup: func(t *testing.T, dir string, + lnd *testutils.LndMockServices) StaticAddressManager { + + writePaidToken( + t, dir, 1, + time.Date(2026, time.April, 14, 9, 30, + 1, 0, time.UTC), + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + }, + }, + { + name: "local pending token", + setup: func(t *testing.T, dir string, + lnd *testutils.LndMockServices) StaticAddressManager { + + writePendingToken( + t, dir, 1, + time.Date(2026, time.April, 14, 9, 30, + 1, 0, time.UTC), + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + }, + }, + { + name: "local static address", + setup: func(t *testing.T, _ string, + lnd *testutils.LndMockServices) StaticAddressManager { + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, + 144, 321, + ) + + return &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + staticMgr := test.setup(t, dir, lnd) + + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + backupTokenID := testTokenID(0x20) + writeBackupPayload( + t, svc, dir, backupTokenID, 2, + validBackupPayload(t, backupTokenID, 2), + ) + + result, restored, err := svc.RestoreLatestOnFreshInstall( + ctx, + ) + require.NoError(t, err) + require.False(t, restored) + require.Nil(t, result) + }) + } +} + +// TestRestoreRejectsNetworkMismatch verifies that a backup from another Loop +// network cannot be restored into the current network data directory. +func TestRestoreRejectsNetworkMismatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + sourceSvc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ), + }, nil, + ) + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + t.TempDir(), "mainnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "does not match") +} + +// TestRestoreRejectsInvalidPayloadMetadata verifies that unsupported or +// incomplete backup payload metadata is rejected before any local state is +// restored. +func TestRestoreRejectsInvalidPayloadMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*backupPayload) + expectedErr string + }{ + { + name: "unsupported version", + mutate: func(payload *backupPayload) { + payload.Version = backupVersion + 1 + }, + expectedErr: "unsupported backup version", + }, + { + name: "missing network", + mutate: func(payload *backupPayload) { + payload.Network = "" + }, + expectedErr: "backup file is missing a network", + }, + { + name: "missing token ID", + mutate: func(payload *backupPayload) { + payload.L402TokenID = "" + }, + expectedErr: "backup file is missing an L402 token ID", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + tokenID := testTokenID(0x10) + payload := validBackupPayload(t, tokenID, 1) + test.mutate(payload) + + backupFile := writeBackupPayload(t, svc, dir, tokenID, 1, payload) + + _, err := svc.Restore(context.Background(), backupFile) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} + +// TestRestoreRejectsIncompleteRecoverableGeneration verifies that restore does +// not accept backups that lack either side of the documented paid-L402/static +// address generation. +func TestRestoreRejectsIncompleteRecoverableGeneration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*backupPayload) + expectedErr string + }{ + { + name: "missing paid token data", + mutate: func(payload *backupPayload) { + payload.TokenFiles = nil + }, + expectedErr: "missing paid L402 token data", + }, + { + name: "missing static address", + mutate: func(payload *backupPayload) { + payload.StaticAddress = nil + }, + expectedErr: "missing static address parameters", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, backupDir, 1, + time.Date( + 2026, time.April, 14, 9, 30, 1, 0, + time.UTC, + ), + ) + + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + test.mutate(payload) + mutatedBackup := writeBackupPayload( + t, sourceSvc, backupDir, payload.L402TokenID, + payload.L402TokenCreatedAt, payload, + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, mutatedBackup) + require.ErrorContains(t, err, test.expectedErr) + require.Empty(t, destStaticMgr.restoreCalls) + }) + } +} + +// TestRestoreRejectsTokenMetadataMismatch verifies that the raw token bytes in +// the backup must match the payload's generation metadata before restore writes +// any state. +func TestRestoreRejectsTokenMetadataMismatch(t *testing.T) { + t.Parallel() + + originalCreatedAt := time.Date( + 2026, time.April, 14, 9, 30, 1, 0, time.UTC, + ) + + tests := []struct { + name string + tokenSeed byte + tokenCreatedAt time.Time + expectedErr string + }{ + { + name: "token ID mismatch", + tokenSeed: 2, + tokenCreatedAt: originalCreatedAt, + expectedErr: "does not match payload token ID", + }, + { + name: "creation time mismatch", + tokenSeed: 1, + tokenCreatedAt: time.Date( + 2026, time.April, 14, 10, 30, 1, 0, + time.UTC, + ), + expectedErr: "does not match payload creation time", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken(t, backupDir, 1, originalCreatedAt) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + otherTokenDir := t.TempDir() + writePaidToken( + t, otherTokenDir, test.tokenSeed, + test.tokenCreatedAt, + ) + otherToken, err := os.ReadFile( + filepath.Join(otherTokenDir, paidTokenFileName), + ) + require.NoError(t, err) + + payload.TokenFiles = []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: otherToken, + }} + mutatedBackup := writeBackupPayload( + t, sourceSvc, backupDir, payload.L402TokenID, + payload.L402TokenCreatedAt, payload, + ) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, mutatedBackup) + require.ErrorContains(t, err, test.expectedErr) + require.Empty(t, destStaticMgr.restoreCalls) + + _, err = os.Stat(filepath.Join(restoreDir, paidTokenFileName)) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + +// TestRestoreRejectsExplicitFilenamePayloadTokenIDMismatch verifies that an +// explicit path is held to the same filename/payload token-ID consistency check +// used during latest-backup discovery. +func TestRestoreRejectsExplicitFilenamePayloadTokenIDMismatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, backupDir, 1, + time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + key, err := sourceSvc.deriveEncryptionKey(ctx) + require.NoError(t, err) + + payload, err := readBackupPayload(key, backupFile) + require.NoError(t, err) + + mismatchedBackup := writeBackupPayload( + t, sourceSvc, backupDir, testTokenID(2), + payload.L402TokenCreatedAt, payload, + ) + + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{chainParams: lnd.ChainParams}, nil, + ) + + _, err = restoreSvc.Restore(ctx, mismatchedBackup) + require.ErrorContains(t, err, "backup file token ID") +} + +// TestPrepareStaticAddressRestoreRejectsInvalidBackup verifies that malformed +// static-address backup fields are rejected before RestoreAddress is called. +func TestPrepareStaticAddressRestoreRejectsInvalidBackup(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + + baseBackup := func() *staticAddressBackup { + return &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey. + SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey. + SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: int32( + addrParams.KeyLocator.Family, + ), + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + } + + _, unrelatedPubKey := testutils.CreateKey(99) + tests := []struct { + name string + mutate func(*staticAddressBackup) + expectedErr string + }{ + { + name: "invalid protocol version", + mutate: func(backup *staticAddressBackup) { + backup.ProtocolVersion = 99 + }, + expectedErr: "invalid static address protocol version", + }, + { + name: "missing client pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = nil + }, + expectedErr: "missing the static address client pubkey", + }, + { + name: "missing legacy client key family", + mutate: func(backup *staticAddressBackup) { + backup.LegacyClientKeyFamily = 0 + }, + expectedErr: "missing the legacy static address " + + "client key family", + }, + { + name: "malformed client pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = []byte{0x01, 0x02} + }, + expectedErr: "public key", + }, + { + name: "malformed server pubkey", + mutate: func(backup *staticAddressBackup) { + backup.ServerPubKey = []byte{0x01, 0x02} + }, + expectedErr: "public key", + }, + { + name: "client key not found", + mutate: func(backup *staticAddressBackup) { + backup.ClientPubKey = unrelatedPubKey. + SerializeCompressed() + }, + expectedErr: "unable to derive static address client key", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + }, nil, + ) + backup := baseBackup() + test.mutate(backup) + + _, err := svc.prepareStaticAddressRestore(ctx, backup) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} + +// TestRestoreRejectsExplicitPathWithInvalidName verifies that explicit restore +// paths must use the documented backup filename format. +func TestRestoreRejectsExplicitPathWithInvalidName(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + + restoreSvc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + invalidPath := filepath.Join(t.TempDir(), "backup.enc") + err := os.WriteFile(invalidPath, []byte("invalid"), 0600) + require.NoError(t, err) + + _, err = restoreSvc.Restore(ctx, invalidPath) + require.ErrorContains(t, err, "invalid backup file path") +} + +// TestRestoreFailsWithoutStaticAddressManager verifies that backups containing +// static-address state cannot be restored when the daemon has no static-address +// manager configured. +func TestRestoreFailsWithoutStaticAddressManager(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, nil, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "static address restore is unavailable") +} + +// TestRestoreRejectsDifferentExistingTokenBeforeStaticAddress verifies that a +// conflicting local L402 token blocks restore before static-address state is +// modified. +func TestRestoreRejectsDifferentExistingTokenBeforeStaticAddress(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(restoreDir, paidTokenFileName), + []byte("conflicting-token"), 0600, + ) + require.NoError(t, err) + + destStaticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + destStaticMgr, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "different contents") + require.Empty(t, destStaticMgr.restoreCalls) +} + +// TestRestoreRollsBackTokenFilesOnStaticAddressFailure verifies that token +// files written during restore are removed again if static-address restore +// fails. +func TestRestoreRollsBackTokenFilesOnStaticAddressFailure(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + backupDir := t.TempDir() + restoreDir := t.TempDir() + + addrParams := makeStaticAddressParams( + t, lnd, 3, defaultRecoveryServerPubkey, 144, 321, + ) + sourceSvc := NewService( + backupDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + writePaidToken( + t, backupDir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + backupFile, err := sourceSvc.WriteBackup(ctx) + require.NoError(t, err) + + restoreSvc := NewService( + restoreDir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + restoreErr: errors.New("restore address failed"), + }, nil, + ) + + _, err = restoreSvc.Restore(ctx, backupFile) + require.ErrorContains(t, err, "restore address failed") + + _, err = os.Stat(filepath.Join(restoreDir, paidTokenFileName)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// TestResolveClientKeyScansLegacyClientFamilyReadOnly verifies that locating the +// backed-up static-address client key scans existing keys without advancing the +// wallet's next-key index. +func TestResolveClientKeyScansLegacyClientFamilyReadOnly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + targetIndex := uint32(5) + addrParams := makeStaticAddressParams( + t, lnd, targetIndex, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + + backup := &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey.SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey.SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + + clientKey, locator, err := svc.resolveClientKey(ctx, backup) + require.NoError(t, err) + require.Equal(t, addrParams.KeyLocator, locator) + require.True(t, clientKey.IsEqual(addrParams.ClientPubkey)) + + nextKey, err := lnd.WalletKit.DeriveNextKey( + ctx, swap.StaticAddressKeyFamily, + ) + require.NoError(t, err) + require.EqualValues(t, 0, nextKey.Index) +} + +// TestResolveClientKeyScansLegacyClientFamily verifies that restore can find a +// backed-up static-address client key within the configured legacy client +// family scan range. +func TestResolveClientKeyScansLegacyClientFamily(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lnd := testutils.NewMockLnd() + targetIndex := uint32(7) + addrParams := makeStaticAddressParams( + t, lnd, targetIndex, defaultRecoveryServerPubkey, 144, 321, + ) + staticMgr := &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + } + svc := NewService( + t.TempDir(), "testnet", lnd.Signer, lnd.WalletKit, + staticMgr, nil, + ) + + backup := &staticAddressBackup{ + ProtocolVersion: uint32(addrParams.ProtocolVersion), + ClientPubKey: addrParams.ClientPubkey.SerializeCompressed(), + ServerPubKey: addrParams.ServerPubkey.SerializeCompressed(), + Expiry: addrParams.Expiry, + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + LegacyFirstHeight: addrParams.InitiationHeight, + MultiAddressFirstHeight: addrParams.InitiationHeight, + } + + clientKey, locator, err := svc.resolveClientKey(ctx, backup) + require.NoError(t, err) + require.Equal(t, addrParams.KeyLocator, locator) + require.True(t, clientKey.IsEqual(addrParams.ClientPubkey)) +} + +// TestResolveClientKeyFamilySelection verifies the V0 restore path scans the +// concrete legacy client-key family, while keeping the future multi-address +// receive/change families out of the lookup. +func TestResolveClientKeyFamilySelection(t *testing.T) { + t.Parallel() + + ctx := context.Background() + targetIndex := uint32(6) + _, expectedPubKey := testutils.CreateKey(200) + + tests := []struct { + name string + backup *staticAddressBackup + expectedLookupFamily int32 + }{ + { + name: "explicit legacy client family wins", + backup: &staticAddressBackup{ + ClientPubKey: expectedPubKey.SerializeCompressed(), + LegacyClientKeyFamily: swap.StaticAddressKeyFamily, + MainKeyFamily: swap.StaticMultiAddressKeyFamily, + ChangeKeyFamily: swap.StaticAddressChangeKeyFamily, + }, + expectedLookupFamily: swap.StaticAddressKeyFamily, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + walletKit := &familyScopedWalletKit{ + expectedFamily: test.expectedLookupFamily, + expectedIndex: targetIndex, + expectedPubKey: expectedPubKey, + } + svc := NewService( + t.TempDir(), "testnet", nil, walletKit, nil, nil, + ) + + clientKey, locator, err := svc.resolveClientKey( + ctx, test.backup, + ) + require.NoError(t, err) + require.True(t, clientKey.IsEqual(expectedPubKey)) + require.Equal( + t, keychain.KeyFamily(test.expectedLookupFamily), + locator.Family, + ) + require.Equal(t, targetIndex, locator.Index) + require.NotEmpty(t, walletKit.calls) + + for _, call := range walletKit.calls { + require.Equal( + t, + keychain.KeyFamily(test.expectedLookupFamily), + call.Family, + ) + } + }) + } +} + +// TestRestoreTokenFiles verifies that paid token material is restored exactly +// once and that restoring identical token bytes is idempotent. +func TestRestoreTokenFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + svc := &Service{ + dataDir: dir, + } + + restoreResult, err := svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("paid-token"), + }}) + require.NoError(t, err) + require.True(t, restoreResult.restored) + + paidToken, err := os.ReadFile(filepath.Join(dir, "l402.token")) + require.NoError(t, err) + require.Equal(t, []byte("paid-token"), paidToken) + + restoreResult, err = svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("paid-token"), + }}) + require.NoError(t, err) + require.False(t, restoreResult.restored) +} + +// TestRestoreTokenFilesRejectsDifferentExistingToken verifies that restore +// refuses to overwrite existing paid token material with different bytes. +func TestRestoreTokenFilesRejectsDifferentExistingToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, "l402.token"), []byte("current-token"), 0600, + ) + require.NoError(t, err) + + svc := &Service{ + dataDir: dir, + } + + _, err = svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token", + Data: []byte("backup-token"), + }}) + require.ErrorContains(t, err, "different contents") +} + +// TestRestoreTokenFilesRejectsExistingPendingToken verifies that explicit +// restore refuses to mix backed-up paid token material with local pending L402 +// acquisition state. +func TestRestoreTokenFilesRejectsExistingPendingToken(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := os.WriteFile( + filepath.Join(dir, pendingTokenFileName), + []byte("pending-token"), 0600, + ) + require.NoError(t, err) + + svc := &Service{ + dataDir: dir, + } + + _, err = svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: []byte("paid-token"), + }}) + require.ErrorContains(t, err, "unexpected file") + require.ErrorContains(t, err, pendingTokenFileName) + + _, err = os.Stat(filepath.Join(dir, paidTokenFileName)) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// TestRestoreTokenFilesRejectsInvalidName verifies that backup payloads cannot +// write arbitrary or pending-token filenames into the token store. +func TestRestoreTokenFilesRejectsInvalidName(t *testing.T) { + t.Parallel() + + svc := &Service{ + dataDir: t.TempDir(), + } + + _, err := svc.restoreTokenFiles([]*l402TokenFileEntry{{ + Name: "l402.token.pending", + Data: []byte("pending-token"), + }}) + require.ErrorContains(t, err, "unexpected token file name") +} + +// TestLatestBackupFilePathIgnoresMalformedNames verifies that files which do +// not match the backup filename grammar are ignored during latest-backup +// selection. +func TestLatestBackupFilePathIgnoresMalformedNames(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + lnd := testutils.NewMockLnd() + + addrParams := makeStaticAddressParams( + t, lnd, 7, defaultRecoveryServerPubkey, 144, 321, + ) + svc := NewService( + dir, "testnet", lnd.Signer, lnd.WalletKit, + &mockStaticAddressManager{ + chainParams: lnd.ChainParams, + params: addrParams, + }, nil, + ) + + writePaidToken( + t, dir, 1, time.Date(2026, time.April, 14, 9, 30, 1, 0, time.UTC), + ) + validPath, err := svc.WriteBackup(context.Background()) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join(dir, "L402_backup_not-an-id.enc"), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + err = os.WriteFile( + filepath.Join(dir, backupFileName(stringsOfLength(64), 1)), + []byte("invalid"), 0600, + ) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + latestFile, err := latestBackupFilePath(dir, key, "testnet") + require.NoError(t, err) + require.Equal(t, validPath, latestFile) +} + +// TestWriteFileAtomically verifies that backup files are written with private +// permissions and that failed atomic writes clean up their temporary files. +func TestWriteFileAtomically(t *testing.T) { + t.Parallel() + + t.Run("uses private permissions", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup.enc") + err := writeFileAtomically(path, []byte("backup")) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + }) + + t.Run("cleans temp file on rename error", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup-target") + err := os.Mkdir(path, 0700) + require.NoError(t, err) + + err = writeFileAtomically(path, []byte("backup")) + require.Error(t, err) + + _, err = os.Stat(path + ".tmp") + require.ErrorIs(t, err, os.ErrNotExist) + }) +} + +var defaultRecoveryServerPubkey = func() *btcec.PublicKey { + _, pubKey := testutils.CreateKey(42) + return pubKey +}() + +type deriveSharedKeyCall struct { + pubKey *btcec.PublicKey + locator *keychain.KeyLocator +} + +type fixedKeySigner struct { + lndclient.SignerClient + + key [32]byte + calls []deriveSharedKeyCall +} + +func (s *fixedKeySigner) DeriveSharedKey(_ context.Context, + pubKey *btcec.PublicKey, locator *keychain.KeyLocator) ([32]byte, + error) { + + call := deriveSharedKeyCall{ + pubKey: pubKey, + } + if locator != nil { + locatorCopy := *locator + call.locator = &locatorCopy + } + s.calls = append(s.calls, call) + + return s.key, nil +} + +type familyScopedWalletKit struct { + lndclient.WalletKitClient + + expectedFamily int32 + expectedIndex uint32 + expectedPubKey *btcec.PublicKey + calls []keychain.KeyLocator +} + +func (w *familyScopedWalletKit) DeriveKey(_ context.Context, + locator *keychain.KeyLocator) (*keychain.KeyDescriptor, error) { + + if locator == nil { + return nil, fmt.Errorf("missing key locator") + } + + w.calls = append(w.calls, *locator) + + if int32(locator.Family) == w.expectedFamily && + locator.Index == w.expectedIndex { + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: w.expectedPubKey, + }, nil + } + + _, pubKey := testutils.CreateKey( + 10_000 + int32(locator.Index) + int32(locator.Family), + ) + + return &keychain.KeyDescriptor{ + KeyLocator: *locator, + PubKey: pubKey, + }, nil +} + +func testBackupKey(seed byte) [32]byte { + var key [32]byte + for idx := range key { + key[idx] = seed + } + + return key +} + +func testTokenID(seed byte) string { + var tokenID l402.TokenID + tokenID[0] = seed + + return tokenID.String() +} + +func validBackupPayload(t *testing.T, tokenID string, + tokenCreatedAt int64) *backupPayload { + + t.Helper() + + parsedTokenID, err := l402.MakeIDFromString(tokenID) + require.NoError(t, err) + + var ( + paymentHash lntypes.Hash + preimage lntypes.Preimage + ) + paymentHash[0] = parsedTokenID[0] + preimage[0] = parsedTokenID[0] + + return &backupPayload{ + Version: backupVersion, + Network: "testnet", + L402TokenID: tokenID, + L402TokenCreatedAt: tokenCreatedAt, + StaticAddress: &staticAddressBackup{}, + TokenFiles: []*l402TokenFileEntry{{ + Name: paidTokenFileName, + Data: tokenFileData( + t, parsedTokenID, paymentHash, preimage, + parsedTokenID[0], time.Unix(0, tokenCreatedAt), + ), + }}, + } +} + +func writeBackupPayload(t *testing.T, svc *Service, dir, tokenID string, + titleTimestamp int64, payload *backupPayload) string { + + t.Helper() + + plaintext, err := json.Marshal(payload) + require.NoError(t, err) + + key, err := svc.deriveEncryptionKey(context.Background()) + require.NoError(t, err) + + encrypted, err := encryptBackupPayload(key, plaintext) + require.NoError(t, err) + + path := backupFilePath(dir, tokenID, titleTimestamp) + err = os.WriteFile(path, encrypted, 0600) + require.NoError(t, err) + + return path +} + +type mockStaticAddressManager struct { + chainParams *chaincfg.Params + params *address.Parameters + currentHeight int32 + getParamsErr error + restoreErr error + restoreCalls []*address.Parameters + restoreChanged bool + restoreChangedSet bool +} + +func (m *mockStaticAddressManager) GetStaticAddressParameters( + context.Context) (*address.Parameters, error) { + + switch { + case m.getParamsErr != nil: + return nil, m.getParamsErr + + case m.params == nil: + return nil, address.ErrNoStaticAddress + + default: + return cloneAddressParameters(m.params), nil + } +} + +func (m *mockStaticAddressManager) CurrentHeight() int32 { + if m.currentHeight > 0 { + return m.currentHeight + } + if m.params != nil { + return m.params.InitiationHeight + } + + return 0 +} + +func (m *mockStaticAddressManager) GetTaprootAddress(clientPubkey, + serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot, + error) { + + return taprootAddress( + clientPubkey, serverPubkey, expiry, m.chainParams, + ) +} + +func (m *mockStaticAddressManager) RestoreAddress(_ context.Context, + params *address.Parameters) (*btcutil.AddressTaproot, bool, error) { + + if m.restoreErr != nil { + return nil, false, m.restoreErr + } + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(params.Expiry), + params.ClientPubkey, params.ServerPubkey, + ) + if err != nil { + return nil, false, err + } + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, false, err + } + if len(params.PkScript) != 0 && + !bytes.Equal(params.PkScript, pkScript) { + + return nil, false, fmt.Errorf("static address pk script mismatch") + } + + params.PkScript = pkScript + m.restoreCalls = append(m.restoreCalls, cloneAddressParameters(params)) + + changed := true + if m.restoreChangedSet { + changed = m.restoreChanged + } + + addr, err := m.GetTaprootAddress( + params.ClientPubkey, params.ServerPubkey, int64(params.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil +} + +type mockDepositManager struct { + depositsFound int + err error + calls int +} + +func (m *mockDepositManager) ReconcileDeposits(context.Context) (int, error) { + m.calls++ + return m.depositsFound, m.err +} + +func makeStaticAddressParams(t *testing.T, lnd *testutils.LndMockServices, + index uint32, serverPubKey *btcec.PublicKey, expiry uint32, + initiationHeight int32) *address.Parameters { + + t.Helper() + + keyDesc, err := lnd.WalletKit.DeriveKey( + context.Background(), &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: index, + }, + ) + require.NoError(t, err) + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, int64(expiry), keyDesc.PubKey, + serverPubKey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + return &address.Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: serverPubKey, + Expiry: expiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: staticaddrversion.ProtocolVersion_V0, + InitiationHeight: initiationHeight, + } +} + +func cloneAddressParameters(params *address.Parameters) *address.Parameters { + if params == nil { + return nil + } + + return &address.Parameters{ + ClientPubkey: params.ClientPubkey, + ServerPubkey: params.ServerPubkey, + Expiry: params.Expiry, + PkScript: slices.Clone(params.PkScript), + KeyLocator: params.KeyLocator, + ProtocolVersion: params.ProtocolVersion, + InitiationHeight: params.InitiationHeight, + } +} + +func taprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, expiry int64, + chainParams *chaincfg.Params) (*btcutil.AddressTaproot, error) { + + staticAddress, err := staticaddrscript.NewStaticAddress( + input.MuSig2Version100RC2, expiry, clientPubkey, serverPubkey, + ) + if err != nil { + return nil, err + } + + return btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), chainParams, + ) +} + +func writePaidToken(t *testing.T, dir string, seed byte, + createdAt time.Time) string { + + t.Helper() + + return writeTokenFile( + t, filepath.Join(dir, paidTokenFileName), seed, createdAt, true, + ) +} + +func writePendingToken(t *testing.T, dir string, seed byte, + createdAt time.Time) string { + + t.Helper() + + return writeTokenFile( + t, filepath.Join(dir, "l402.token.pending"), seed, createdAt, false, + ) +} + +func writeTokenFile(t *testing.T, path string, seed byte, createdAt time.Time, + paid bool) string { + + t.Helper() + + var ( + paymentHash lntypes.Hash + tokenID l402.TokenID + preimage lntypes.Preimage + ) + paymentHash[0] = seed + tokenID[0] = seed + if paid { + preimage[0] = seed + } + + data := tokenFileData( + t, tokenID, paymentHash, preimage, seed, createdAt, + ) + err := os.WriteFile(path, data, 0600) + require.NoError(t, err) + + return tokenID.String() +} + +func tokenFileData(t *testing.T, tokenID l402.TokenID, + paymentHash lntypes.Hash, preimage lntypes.Preimage, seed byte, + createdAt time.Time) []byte { + + t.Helper() + + var idBytes bytes.Buffer + err := l402.EncodeIdentifier(&idBytes, &l402.Identifier{ + Version: l402.LatestVersion, + PaymentHash: paymentHash, + TokenID: tokenID, + }) + require.NoError(t, err) + + mac, err := macaroon.New( + []byte("loop-recovery-test-root-key"), + idBytes.Bytes(), "loop.test", macaroon.LatestVersion, + ) + require.NoError(t, err) + + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + + var serialized bytes.Buffer + err = binary.Write(&serialized, binary.BigEndian, uint32(len(macBytes))) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, macBytes) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, paymentHash) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, preimage) + require.NoError(t, err) + err = binary.Write( + &serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*1000, + ) + require.NoError(t, err) + err = binary.Write( + &serialized, binary.BigEndian, lnwire.MilliSatoshi(seed)*10, + ) + require.NoError(t, err) + err = binary.Write(&serialized, binary.BigEndian, createdAt.UnixNano()) + require.NoError(t, err) + + return serialized.Bytes() +} + +func listBackupFiles(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + + var files []string + for _, entry := range entries { + if _, ok := backupFileTokenID(entry.Name()); ok { + files = append(files, filepath.Join(dir, entry.Name())) + } + } + + slices.Sort(files) + return files +} + +func copyFile(t *testing.T, src, dest string) { + t.Helper() + + data, err := os.ReadFile(src) + require.NoError(t, err) + + err = os.WriteFile(dest, data, 0600) + require.NoError(t, err) +} + +func stringsOfLength(length int) string { + return string(bytes.Repeat([]byte("a"), length)) +} diff --git a/staticaddr/address/interface.go b/staticaddr/address/interface.go index 63b6cf7c1..c3583e059 100644 --- a/staticaddr/address/interface.go +++ b/staticaddr/address/interface.go @@ -6,15 +6,17 @@ 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 // GetAllStaticAddresses retrieves all static addresses from the store. - GetAllStaticAddresses(ctx context.Context) ([]*script.Parameters, - error) + GetAllStaticAddresses(ctx context.Context) ([]*Parameters, error) } diff --git a/staticaddr/address/manager.go b/staticaddr/address/manager.go index 322eed739..d4ab0c466 100644 --- a/staticaddr/address/manager.go +++ b/staticaddr/address/manager.go @@ -3,7 +3,9 @@ package address import ( "bytes" "context" + "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 @@ -79,6 +87,13 @@ func NewManager(cfg *ManagerConfig, currentHeight int32) (*Manager, error) { return m, nil } +// CurrentHeight returns the manager's latest observed block height. Recovery +// stores this height as the scan floor for the future multi-address generation +// rooted in the current paid L402. +func (m *Manager) CurrentHeight() int32 { + return m.currentHeight.Load() +} + // Run runs the address manager. func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { newBlockChan, newBlockErrChan, err := @@ -122,11 +137,27 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, return nil, 0, err } if len(addresses) > 0 { - clientPubKey := addresses[0].ClientPubkey - serverPubKey := addresses[0].ServerPubkey - expiry := int64(addresses[0].Expiry) + addrParams := addresses[0] + clientPubKey := addrParams.ClientPubkey + serverPubKey := addrParams.ServerPubkey + expiry := int64(addrParams.Expiry) + m.Unlock() - defer m.Unlock() + // Re-import the tapscript even when the address row already exists. + // This keeps the call idempotent while repairing cases where restore + // or startup left the DB populated before lnd imported the script. + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, expiry, clientPubKey, + serverPubKey, + ) + if err != nil { + return nil, 0, err + } + + _, err = m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, 0, err + } address, err := m.GetTaprootAddress( clientPubKey, serverPubKey, expiry, @@ -209,24 +240,20 @@ func (m *Manager) NewAddress(ctx context.Context) (*btcutil.AddressTaproot, ), 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/recovery attempt 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 } - // 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 } - log.Infof("Imported static address taproot script to lnd wallet: %v", - addr) - address, err := m.GetTaprootAddress( clientPubKey.PubKey, serverPubKey, int64(serverParams.Expiry), ) @@ -272,6 +299,158 @@ func validateServerAddressParams( return nil } +// RestoreAddress recreates a static address record locally and makes sure the +// corresponding tapscript is imported into lnd. Recovery passes already-derived +// address parameters here; this method owns the DB/import ordering so a failed +// lnd import cannot leave behind an untracked DB-only address. If the same +// address already exists locally, the call is idempotent. +func (m *Manager) RestoreAddress(ctx context.Context, + addrParams *Parameters) (*btcutil.AddressTaproot, bool, error) { + + if addrParams == nil { + return nil, false, fmt.Errorf("missing static address parameters") + } + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(addrParams.Expiry), + addrParams.ClientPubkey, addrParams.ServerPubkey, + ) + if err != nil { + return nil, false, err + } + + pkScript, err := staticAddress.StaticAddressScript() + if err != nil { + return nil, false, err + } + + if len(addrParams.PkScript) != 0 && + !bytes.Equal(addrParams.PkScript, pkScript) { + + return nil, false, fmt.Errorf("static address pk script mismatch") + } + + addrParams.PkScript = pkScript + if addrParams.InitiationHeight <= 0 { + addrParams.InitiationHeight = m.currentHeight.Load() + } + + m.Lock() + existing, err := m.cfg.Store.GetAllStaticAddresses(ctx) + if err != nil { + m.Unlock() + + return nil, false, err + } + + var changed bool + switch { + case len(existing) == 0: + // Import before creating the restored DB row. If import fails, the + // next recovery attempt should still treat the address as missing + // instead of getting stuck on an untracked DB-only address. + _, err := m.importAddressTapscript(ctx, staticAddress) + if err != nil { + m.Unlock() + + return nil, false, err + } + + err = m.cfg.Store.CreateStaticAddress(ctx, addrParams) + if err != nil { + m.Unlock() + + return nil, false, err + } + changed = true + + case len(existing) > 1: + m.Unlock() + + return nil, false, fmt.Errorf("more than one static address found") + + case !sameAddressParameters(existing[0], addrParams): + m.Unlock() + + return nil, false, fmt.Errorf("existing static address differs from " + + "backup") + + default: + m.Unlock() + + // The DB row already matches the backup. Re-import anyway so + // restore is idempotent and can repair a prior partial restore where + // lnd never learned the tapscript. + imported, err := m.importAddressTapscript(ctx, staticAddress) + if err != nil { + return nil, false, err + } + + changed = imported + + addr, err := m.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil + } + m.Unlock() + + addr, err := m.GetTaprootAddress( + addrParams.ClientPubkey, addrParams.ServerPubkey, + int64(addrParams.Expiry), + ) + if err != nil { + return nil, false, err + } + + return addr, changed, nil +} + +func (m *Manager) importAddressTapscript(ctx context.Context, + staticAddress *script.StaticAddress) (bool, 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 { + // Restoring into an lnd instance that already imported the script is + // expected. Treat the duplicate import as success. + if strings.Contains(err.Error(), "already exists") { + log.Infof("Static address tapscript already imported") + return false, nil + } + + return false, err + } + + log.Infof("Imported static address taproot script to lnd wallet: %v", + addr) + + return true, nil +} + +func sameAddressParameters(a, b *Parameters) bool { + if a == nil || b == nil { + return false + } + + return a.ClientPubkey.IsEqual(b.ClientPubkey) && + a.ServerPubkey.IsEqual(b.ServerPubkey) && + a.Expiry == b.Expiry && + bytes.Equal(a.PkScript, b.PkScript) && + a.KeyLocator == b.KeyLocator && + a.ProtocolVersion == b.ProtocolVersion && + a.InitiationHeight == b.InitiationHeight +} + // GetTaprootAddress returns a taproot address for the given client and server // public keys and expiry. func (m *Manager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, @@ -337,9 +516,11 @@ func (m *Manager) ListUnspentRaw(ctx context.Context, minConfs, return taprootAddress, filteredUtxos, nil } -// GetStaticAddressParameters returns the parameters of the static address. -func (m *Manager) GetStaticAddressParameters(ctx context.Context) ( - *script.Parameters, error) { +// GetStaticAddressParameters returns the single concrete static-address row +// currently supported by the legacy address manager. Recovery treats the row as +// the V0 address that can be backed up and restored directly. +func (m *Manager) GetStaticAddressParameters(ctx context.Context) (*Parameters, + error) { params, err := m.cfg.Store.GetAllStaticAddresses(ctx) if err != nil { @@ -347,7 +528,7 @@ func (m *Manager) GetStaticAddressParameters(ctx context.Context) ( } if len(params) == 0 { - return nil, fmt.Errorf("no static address parameters found") + return nil, ErrNoStaticAddress } return params[0], nil diff --git a/staticaddr/address/manager_test.go b/staticaddr/address/manager_test.go index b7bbf79ae..6a63fa5a7 100644 --- a/staticaddr/address/manager_test.go +++ b/staticaddr/address/manager_test.go @@ -3,12 +3,15 @@ package address import ( "context" "encoding/hex" + "errors" "testing" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" @@ -33,6 +36,18 @@ type mockStaticAddressClient struct { mock.Mock } +type failingImportWalletKit struct { + lndclient.WalletKitClient + + err error +} + +func (w *failingImportWalletKit) ImportTaprootScript(context.Context, + *waddrmgr.Tapscript) (btcutil.Address, error) { + + return nil, w.err +} + func (m *mockStaticAddressClient) ServerStaticAddressLoopIn(ctx context.Context, in *swapserverrpc.ServerStaticAddressLoopInRequest, opts ...grpc.CallOption) ( @@ -132,6 +147,171 @@ 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, + ) +} + +// TestRestoreAddress verifies that restoring an address recreates the same +// static address locally without requiring a server call. +func TestRestoreAddress(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + taprootAddress, restored, err := testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.True(t, restored) + + expectedAddress, err := btcutil.NewAddressTaproot( + schnorr.SerializePubKey(staticAddress.TaprootKey), + testContext.manager.cfg.ChainParams, + ) + require.NoError(t, err) + require.Equal(t, expectedAddress.String(), taprootAddress.String()) + + storedParams, err := testContext.manager.GetStaticAddressParameters(ctxb) + require.NoError(t, err) + require.True(t, sameAddressParameters(storedParams, addressParams)) + + taprootAddress, restored, err = testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.False(t, restored) + require.Equal(t, expectedAddress.String(), taprootAddress.String()) +} + +// TestRestoreAddressImportFailureDoesNotCreateRow verifies that a failed lnd +// tapscript import leaves no static-address DB row behind, so a later retry can +// restore cleanly. +func TestRestoreAddressImportFailureDoesNotCreateRow(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + importErr := errors.New("import failed") + testContext.manager.cfg.WalletKit = &failingImportWalletKit{ + WalletKitClient: testContext.mockLnd.WalletKit, + err: importErr, + } + + _, _, err = testContext.manager.RestoreAddress(ctxb, addressParams) + require.ErrorIs(t, err, importErr) + + _, err = testContext.manager.GetStaticAddressParameters(ctxb) + require.ErrorIs(t, err, ErrNoStaticAddress) + + testContext.manager.cfg.WalletKit = testContext.mockLnd.WalletKit + _, restored, err := testContext.manager.RestoreAddress( + ctxb, addressParams, + ) + require.NoError(t, err) + require.True(t, restored) +} + +// TestRestoreAddressRejectsDifferentInitiationHeight verifies that a restore +// request with the same address material but a different initiation height is +// rejected instead of being treated as idempotent. +func TestRestoreAddressRejectsDifferentInitiationHeight(t *testing.T) { + ctxb := t.Context() + + testContext := NewAddressManagerTestContext(t) + + keyDesc, err := testContext.mockLnd.WalletKit.DeriveKey( + ctxb, &keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 7, + }, + ) + require.NoError(t, err) + + staticAddress, err := script.NewStaticAddress( + input.MuSig2Version100RC2, int64(defaultExpiry), + keyDesc.PubKey, defaultServerPubkey, + ) + require.NoError(t, err) + + pkScript, err := staticAddress.StaticAddressScript() + require.NoError(t, err) + + addressParams := &Parameters{ + ClientPubkey: keyDesc.PubKey, + ServerPubkey: defaultServerPubkey, + Expiry: defaultExpiry, + PkScript: pkScript, + KeyLocator: keyDesc.KeyLocator, + ProtocolVersion: 0, + InitiationHeight: 123, + } + + _, _, err = testContext.manager.RestoreAddress(ctxb, addressParams) + require.NoError(t, err) + + differentHeight := *addressParams + differentHeight.InitiationHeight = 456 + + _, _, err = testContext.manager.RestoreAddress(ctxb, &differentHeight) + require.ErrorContains(t, err, "existing static address differs from backup") } // TestNewAddressValidatesServerResponse tests that the untrusted diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index a1d0581d4..ae0acc392 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -70,7 +70,8 @@ type Manager struct { // mu guards access to the activeDeposits map. mu sync.Mutex - // reconcileMu serializes deposit reconciliation so new deposits are + // reconcileMu serializes deposit recovery and reconciliation so restore + // requests can't race the background polling loop, and new deposits are // discovered and retained exactly once per outpoint. reconcileMu sync.Mutex @@ -134,7 +135,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { // Reconcile immediately on startup so deposits are available // before the first ticker fires. - err = m.reconcileDeposits(ctx) + _, err = m.ReconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) } @@ -158,7 +159,7 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { case height := <-newBlockChan: m.currentHeight.Store(uint32(height)) - err := m.reconcileDeposits(ctx) + _, err := m.ReconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) } @@ -212,6 +213,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. @@ -270,7 +274,7 @@ func (m *Manager) pollDeposits(ctx context.Context) { for { select { case <-ticker.C: - err := m.reconcileDeposits(ctx) + _, err := m.ReconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile "+ "deposits: %v", err) @@ -289,60 +293,69 @@ func (m *Manager) pollDeposits(ctx context.Context) { // 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) + _, err := m.ReconcileDeposits(ctx) + return err } // 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 // deposit to track its progress. -func (m *Manager) reconcileDeposits(ctx context.Context) error { - m.reconcileMu.Lock() - defer m.reconcileMu.Unlock() - +func (m *Manager) reconcileDeposits(ctx context.Context) (int, error) { log.Tracef("Reconciling new deposits...") utxos, err := m.cfg.AddressManager.ListUnspent( ctx, 0, MaxConfs, ) if err != nil { - return fmt.Errorf("unable to list new deposits: %w", err) + return 0, 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 "+ + return 0, fmt.Errorf("unable to update deposit "+ "confirmations: %w", err) } err = m.syncActiveDeposits(ctx, utxos) if err != nil { - return fmt.Errorf("unable to sync active deposits: %w", err) + return 0, fmt.Errorf("unable to sync active deposits: %w", err) } newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") - return nil + return 0, nil } for _, utxo := range newDeposits { deposit, err := m.createNewDeposit(ctx, utxo, currentHeight) if err != nil { - return fmt.Errorf("unable to retain new deposit: %w", + return 0, fmt.Errorf("unable to retain new deposit: %w", err) } log.Debugf("Received deposit: %v", deposit) err = m.startDepositFsm(ctx, deposit) if err != nil { - return fmt.Errorf("unable to start new deposit FSM: %w", + return 0, fmt.Errorf("unable to start new deposit FSM: %w", err) } } - return nil + return len(newDeposits), nil +} + +// ReconcileDeposits triggers a best-effort reconciliation pass and returns the +// number of newly discovered deposits. Recovery calls this after restoring the +// address because deposit FSM state is not serialized in backups; it must be +// rebuilt from lnd's current wallet view. +func (m *Manager) ReconcileDeposits(ctx context.Context) (int, error) { + m.reconcileMu.Lock() + defer m.reconcileMu.Unlock() + + return m.reconcileDeposits(ctx) } // createNewDeposit transforms the wallet utxo into a deposit struct and stores diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 2e134c26b..7cee61fc8 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -71,14 +71,16 @@ func TestReconcileDepositsSerialized(t *testing.T) { errs := make(chan error, 2) go func() { defer wg.Done() - errs <- manager.reconcileDeposits(ctx) + _, err := manager.ReconcileDeposits(ctx) + errs <- err }() <-createEntered go func() { defer wg.Done() - errs <- manager.reconcileDeposits(ctx) + _, err := manager.ReconcileDeposits(ctx) + errs <- err }() time.Sleep(100 * time.Millisecond) @@ -156,7 +158,7 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { }) manager.currentHeight.Store(100) - err := manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) require.ErrorContains(t, err, "unable to start new deposit FSM") } @@ -237,7 +239,8 @@ func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) { }() manager.activeDeposits[outpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) require.Equal(t, Deposited, deposit.GetState()) require.Empty(t, manager.activeDeposits) select { @@ -285,7 +288,8 @@ func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) { }() manager.activeDeposits[outpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) require.Equal(t, Deposited, deposit.GetState()) require.EqualValues(t, 123, deposit.ConfirmationHeight) require.Empty(t, manager.activeDeposits) @@ -471,7 +475,8 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { // Reconciliation should reactivate the existing record instead of // creating a second deposit entry for the same outpoint. - require.NoError(t, manager.reconcileDeposits(ctx)) + _, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) require.Equal(t, Deposited, deposit.GetState()) require.Zero(t, deposit.ConfirmationHeight) require.Len(t, manager.activeDeposits, 1) @@ -529,7 +534,7 @@ func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) { }) manager.deposits[outpoint] = deposit - err := manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) require.ErrorContains(t, err, "unable to sync active deposits") require.Equal(t, Deposited, deposit.GetState()) require.Zero(t, deposit.ConfirmationHeight) @@ -596,7 +601,7 @@ func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { }() manager.activeDeposits[vanishedOutpoint] = vanishedFsm - err := manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) require.ErrorContains(t, err, "unable to sync active deposits") require.Empty(t, manager.activeDeposits) @@ -670,7 +675,8 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { fsm := &FSM{} manager.activeDeposits[oldOutpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + _, err = manager.reconcileDeposits(ctx) + require.NoError(t, err) require.Same(t, deposit, manager.deposits[oldOutpoint]) require.Equal(t, oldOutpoint, deposit.OutPoint) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 718a644cc..69ee52d64 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -366,6 +366,36 @@ func TestManagerReplaysStartupBlockToRecoveredDeposits(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) +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct { @@ -382,19 +412,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), @@ -405,7 +425,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { Index: 0xffffffff, }, } - require.NoError(t, err) + storedDeposits := []*Deposit{ { ID: ID, @@ -417,6 +437,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) @@ -433,7 +473,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { mockAddressManager.On( "ListUnspent", mock.Anything, mock.Anything, mock.Anything, - ).Return([]*lnwallet.Utxo{utxo}, nil) + ).Return(utxos, nil) // Define the expected return values for the mocks. mockChainNotifier.On( diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 91d3f1949..0aae239be 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -510,6 +510,9 @@ 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, + ) _, routeHints, _, _, err := swap.DecodeInvoice( mockLnd.ChainParams, server.request.SwapInvoice, diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 602d66d0a..d8eae16af 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -11,8 +11,10 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) @@ -316,9 +318,13 @@ func TestCreateLoopIn(t *testing.T) { SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4}, DepositOutpoints: []string{d1.OutPoint.String(), d2.OutPoint.String()}, - Deposits: []*deposit.Deposit{d1, d2}, - ClientPubkey: clientPubKey, - ServerPubkey: serverPubKey, + Deposits: []*deposit.Deposit{d1, d2}, + ClientPubkey: clientPubKey, + ServerPubkey: serverPubKey, + HtlcKeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(swap.StaticAddressKeyFamily), + Index: 37, + }, HtlcTimeoutSweepAddress: addr, } swapPending.SetState(SignHtlcTx) @@ -349,6 +355,7 @@ 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, swapPending.HtlcKeyLocator, swap.HtlcKeyLocator) require.Equal( t, ConfirmationRiskDecisionNone, swap.ConfirmationRiskDecision, 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..99aca8a86 --- /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 recovery and multi-address 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) +} diff --git a/test/signer_mock.go b/test/signer_mock.go index 23ed69533..ba0fa2805 100644 --- a/test/signer_mock.go +++ b/test/signer_mock.go @@ -75,10 +75,23 @@ func (s *mockSigner) VerifyMessage(ctx context.Context, msg, sig []byte, return mockAssertion, nil } -func (s *mockSigner) DeriveSharedKey(context.Context, *btcec.PublicKey, - *keychain.KeyLocator) ([32]byte, error) { +func (s *mockSigner) DeriveSharedKey(_ context.Context, pubKey *btcec.PublicKey, + locator *keychain.KeyLocator) ([32]byte, error) { - return [32]byte{4, 5, 6}, nil + if locator == nil { + return [32]byte{}, fmt.Errorf("missing key locator") + } + if pubKey == nil { + return [32]byte{}, fmt.Errorf("missing pubkey") + } + + privKey, _ := CreateKey(int32(locator.Index)) + sharedSecret := btcec.GenerateSharedSecret(privKey, pubKey) + + var result [32]byte + copy(result[:], sharedSecret) + + return result, nil } // MuSig2CreateSession creates a new MuSig2 signing session using the local diff --git a/test/signer_mock_test.go b/test/signer_mock_test.go new file mode 100644 index 000000000..91685f9a8 --- /dev/null +++ b/test/signer_mock_test.go @@ -0,0 +1,41 @@ +package test + +import ( + "context" + "testing" + + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +func TestMockSignerDeriveSharedKeyDependsOnInputs(t *testing.T) { + t.Parallel() + + signer := NewMockLnd().Signer.(*mockSigner) + _, remotePubKeyA := CreateKey(7) + _, remotePubKeyB := CreateKey(8) + + sharedKeyA0, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyA, &keychain.KeyLocator{ + Index: 0, + }, + ) + require.NoError(t, err) + + sharedKeyA1, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyA, &keychain.KeyLocator{ + Index: 1, + }, + ) + require.NoError(t, err) + + sharedKeyB0, err := signer.DeriveSharedKey( + context.Background(), remotePubKeyB, &keychain.KeyLocator{ + Index: 0, + }, + ) + require.NoError(t, err) + + require.NotEqual(t, sharedKeyA0, sharedKeyA1) + require.NotEqual(t, sharedKeyA0, sharedKeyB0) +} diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index ee42fa162..b80e9314b 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -9,6 +9,7 @@ import ( "time" "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" @@ -33,6 +34,8 @@ type mockWalletKit struct { lnd *LndMockServices keyIndex int32 + importedTaprootScripts map[string]struct{} + feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight minRelayFee chainfee.SatPerKWeight @@ -338,5 +341,21 @@ func (m *mockWalletKit) ImportPublicKey(ctx context.Context, func (m *mockWalletKit) ImportTaprootScript(ctx context.Context, tapscript *waddrmgr.Tapscript) (btcutil.Address, error) { + taprootKey, err := tapscript.TaprootKey() + if err != nil { + return nil, err + } + + if m.importedTaprootScripts == nil { + m.importedTaprootScripts = make(map[string]struct{}) + } + + key := string(schnorr.SerializePubKey(taprootKey)) + if _, ok := m.importedTaprootScripts[key]; ok { + return nil, fmt.Errorf("taproot script already exists") + } + + m.importedTaprootScripts[key] = struct{}{} + return nil, nil }