From 38566a6e7fc14066b7093043a480352957cd7ce7 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 22 Jun 2026 16:53:02 +0200 Subject: [PATCH 1/7] staticaddr/deposit: track unconfirmed deposits Derive confirmation heights from a stable wallet view, retain unconfirmed static-address deposits, mark vanished deposits as replaced after repeated misses, and replay the startup block to recovered deposit FSMs so expiry handling is not missed after restart. --- loopd/daemon.go | 1 + staticaddr/deposit/deposit.go | 20 +- staticaddr/deposit/deposit_test.go | 17 + staticaddr/deposit/fsm.go | 45 +- staticaddr/deposit/manager.go | 485 +++++++++++++-- staticaddr/deposit/manager_height_test.go | 37 ++ staticaddr/deposit/manager_reconcile_test.go | 587 +++++++++++++++++++ staticaddr/deposit/manager_test.go | 102 ++++ 8 files changed, 1237 insertions(+), 57 deletions(-) create mode 100644 staticaddr/deposit/deposit_test.go create mode 100644 staticaddr/deposit/manager_height_test.go create mode 100644 staticaddr/deposit/manager_reconcile_test.go diff --git a/loopd/daemon.go b/loopd/daemon.go index 241d62e0f..4d568f65f 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -622,6 +622,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { depositStore := deposit.NewSqlStore(baseDb) depoCfg := &deposit.ManagerConfig{ AddressManager: staticAddressManager, + ChainKit: d.lnd.ChainKit, Store: depositStore, WalletKit: d.lnd.WalletKit, ChainNotifier: d.lnd.ChainNotifier, diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 4cb64bc95..a7af9e612 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 @@ -45,7 +49,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 @@ -69,15 +74,26 @@ func (d *Deposit) IsInFinalState() bool { d.Lock() defer d.Unlock() + return d.isInFinalStateNoLock() +} + +func (d *Deposit) isInFinalStateNoLock() bool { + // Replaced is inactive from the deposit FSM's point of view. The manager may + // still revive the same record if lnd reports the exact outpoint again after + // a transient wallet-view miss. return d.state == Expired || d.state == Withdrawn || d.state == LoopedIn || d.state == HtlcTimeoutSwept || - d.state == ChannelPublished + d.state == ChannelPublished || d.state == Replaced } 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 6dadd127f..fc592fce7 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" @@ -41,10 +42,26 @@ 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") + // Replaced signals that a deposit disappeared from the wallet view and + // can no longer be spent. + // + // The concrete case we need to handle is mempool replacement: a user can + // receive to the static address, we persist that unconfirmed outpoint, and + // then the funding transaction can be replaced or otherwise evicted before + // confirmation. Once that happens lnd stops returning the old outpoint from + // ListUnspent, but our DB would otherwise keep presenting it as selectable. + // Replaced lets us retain the historic record while making it clear that the + // original outpoint is no longer a live deposit. + // + // This state is managed directly by the deposit manager rather than via + // DepositStatesV0 because it reflects wallet visibility changes such as + // mempool replacement or deep reorgs, not an FSM-driven spend path. + Replaced = fsm.StateType("Replaced") + // Withdrawing signals that the withdrawal transaction has been // broadcast, awaiting sufficient confirmations. Withdrawing = fsm.StateType("Withdrawing") @@ -92,8 +109,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 @@ -160,12 +177,17 @@ 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 sync.Once } // NewFSM creates a new state machine that can action on all static address @@ -191,6 +213,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 +249,9 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, ctx, currentHeight, ) + case <-fsm.stopChan: + return + case <-ctx.Done(): return } @@ -235,6 +261,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 af8820302..eb9e24b33 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" @@ -33,6 +34,17 @@ const ( // PollInterval is the interval in which we poll for new deposits to our // static address. PollInterval = 10 * time.Second + + // vanishedDepositThreshold is the number of consecutive wallet + // observations in which a Deposited outpoint must be missing before we + // mark it replaced. + // + // A single miss can happen during a transient wallet-view gap while lnd is + // processing a replacement or reorg. Requiring two misses keeps that narrow + // race recoverable without leaving vanished deposits selectable forever. At + // the default PollInterval, this means a vanished deposit can remain active + // for up to roughly 20 seconds. + vanishedDepositThreshold = 2 ) // ManagerConfig holds the configuration for the address manager. @@ -41,6 +53,10 @@ type ManagerConfig struct { // address parameters. AddressManager AddressManager + // ChainKit is used to query the best known chain tip when deriving + // confirmation heights from wallet UTXOs. + ChainKit lndclient.ChainKitClient + // Store is the database store that is used to store static address // related records. Store Store @@ -58,15 +74,27 @@ 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 // 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 + // missingDeposits counts consecutive wallet observations in which a + // Deposited outpoint was missing from the wallet view. + missingDeposits map[wire.OutPoint]uint8 + // deposits contain all the deposits that have ever been made to the // static address. This field is used to store and recover deposits. It // also serves as a basis for reconciliation of newly detected deposits @@ -77,6 +105,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. @@ -84,6 +115,7 @@ func NewManager(cfg *ManagerConfig) *Manager { return &Manager{ cfg: cfg, activeDeposits: make(map[wire.OutPoint]*FSM), + missingDeposits: make(map[wire.OutPoint]uint8), deposits: make(map[wire.OutPoint]*Deposit), finalizedDepositChan: make(chan wire.OutPoint), } @@ -98,6 +130,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 { @@ -113,6 +158,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) @@ -123,32 +175,22 @@ 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): + m.currentHeight.Store(uint32(height)) - case <-fsm.quitChan: - continue + err := m.reconcileDeposits(ctx) + if err != nil { + log.Errorf("unable to reconcile deposits: %v", err) + } - case <-ctx.Done(): - return ctx.Err() - } + err = m.notifyActiveDeposits(ctx, uint32(height)) + if err != nil { + return err } 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 @@ -159,6 +201,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 { @@ -207,8 +276,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...") @@ -236,13 +307,36 @@ 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( - ctx, MinConfs, MaxConfs, - ) + utxos, bestHeight, err := m.listUnspentWithBestHeight(ctx) + if err != nil { + return err + } + + err = m.updateDepositConfirmations(ctx, utxos, bestHeight) + if err != nil { + return fmt.Errorf("unable to update deposit "+ + "confirmations: %w", err) + } + + // If the same outpoint reappeared after a transient wallet-view miss, + // reactivate the existing record before we consider it new or vanished. + err = m.reviveReappearedDeposits(ctx, utxos, bestHeight) if err != nil { - return fmt.Errorf("unable to list new deposits: %w", err) + return fmt.Errorf("unable to revive reappeared deposits: %w", + err) + } + + // After handling reappearances, only still-missing outpoints contribute + // towards replacement detection. + err = m.invalidateVanishedDeposits(ctx, utxos) + if err != nil { + return fmt.Errorf("unable to invalidate vanished "+ + "deposits: %w", err) } newDeposits := m.filterNewDeposits(utxos) @@ -252,7 +346,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, bestHeight) if err != nil { return fmt.Errorf("unable to retain new deposit: %w", err) @@ -269,12 +363,73 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { return nil } +// listUnspentWithBestHeight returns the wallet's current static-address UTXOs +// together with a stable chain tip height for any confirmed outputs. +func (m *Manager) listUnspentWithBestHeight(ctx context.Context) ( + []*lnwallet.Utxo, int32, error) { + + utxos, err := m.cfg.AddressManager.ListUnspent( + ctx, 0, MaxConfs, + ) + if err != nil { + return nil, 0, fmt.Errorf("unable to list new deposits: %w", err) + } + + needsBestHeight := false + for _, utxo := range utxos { + if utxo.Confirmations > 0 { + needsBestHeight = true + break + } + } + + if !needsBestHeight { + return utxos, 0, nil + } + + if m.cfg.ChainKit == nil { + return nil, 0, errors.New("chain kit client required for " + + "confirmed deposits") + } + + const maxAttempts = 3 + for range maxAttempts { + _, beforeHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) + if err != nil { + return nil, 0, fmt.Errorf("unable to get best block "+ + "before listing deposits: %w", err) + } + + utxos, err = m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs) + if err != nil { + return nil, 0, fmt.Errorf("unable to list new deposits: %w", + err) + } + + _, afterHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) + if err != nil { + return nil, 0, fmt.Errorf("unable to get best block "+ + "after listing deposits: %w", err) + } + + if beforeHeight == afterHeight { + m.currentHeight.Store(uint32(afterHeight)) + return utxos, afterHeight, nil + } + } + + return nil, 0, errors.New("unable to get stable best block while " + + "listing deposits") +} + // 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, bestHeight int32) (*Deposit, error) { - blockHeight, err := m.getBlockHeight(ctx, utxo) + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, utxo, + ) if err != nil { return nil, err } @@ -302,7 +457,7 @@ func (m *Manager) createNewDeposit(ctx context.Context, state: Deposited, OutPoint: utxo.OutPoint, Value: utxo.Value, - ConfirmationHeight: int64(blockHeight), + ConfirmationHeight: confirmationHeight, TimeOutSweepPkScript: timeoutSweepPkScript, } @@ -318,37 +473,244 @@ 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 a stable best-known chain tip. Unconfirmed UTXOs return 0. +func confirmationHeightForUtxo(bestHeight int32, + 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 + } + + if bestHeight <= 0 { + return 0, fmt.Errorf("invalid best height %d", bestHeight) + } + + firstConfirmationHeight := int64(bestHeight) - utxo.Confirmations + 1 + if firstConfirmationHeight <= 0 { + return 0, fmt.Errorf("invalid confirmation height %d for %v "+ + "with best height %d and %d confirmations", + firstConfirmationHeight, utxo.OutPoint, bestHeight, + utxo.Confirmations) + } + + return firstConfirmationHeight, nil +} + +// 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, bestHeight int32) error { + + for _, utxo := range utxos { + m.mu.Lock() + deposit, ok := m.deposits[utxo.OutPoint] + m.mu.Unlock() + if !ok { + continue + } + + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, utxo, + ) + if err != nil { + return err + } + + deposit.Lock() + if deposit.ConfirmationHeight == confirmationHeight { + deposit.Unlock() + continue + } + + previousConfirmationHeight := deposit.ConfirmationHeight + deposit.ConfirmationHeight = confirmationHeight + + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.Unlock() + return err + } + + deposit.Unlock() + } + + return nil +} + +// reviveReappearedDeposits reactivates deposits that were previously marked as +// replaced if the exact same outpoint reappears in the wallet view. +// +// This is the inverse of invalidateVanishedDeposits: it lets us +// recover from a transient ListUnspent gap without inventing a second record +// for the same outpoint. +func (m *Manager) reviveReappearedDeposits(ctx context.Context, + utxos []*lnwallet.Utxo, bestHeight int32) error { + + type reviveCandidate struct { + deposit *Deposit + utxo *lnwallet.Utxo + } + + var candidates []reviveCandidate + + m.mu.Lock() + for _, utxo := range utxos { + delete(m.missingDeposits, utxo.OutPoint) + + deposit, ok := m.deposits[utxo.OutPoint] + if !ok { + continue + } + + if _, active := m.activeDeposits[utxo.OutPoint]; active { + continue + } + + deposit.Lock() + isReplaced := deposit.IsInStateNoLock(Replaced) + deposit.Unlock() + if !isReplaced { + continue + } + + candidates = append(candidates, reviveCandidate{ + deposit: deposit, + utxo: utxo, + }) } + m.mu.Unlock() - notifChan, errChan, err := - m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &utxo.OutPoint.Hash, addressParams.PkScript, - MinConfs, addressParams.InitiationHeight, + for _, candidate := range candidates { + confirmationHeight, err := confirmationHeightForUtxo( + bestHeight, candidate.utxo, ) - if err != nil { - return 0, err + if err != nil { + return err + } + + deposit := candidate.deposit + deposit.Lock() + if !deposit.IsInStateNoLock(Replaced) { + deposit.Unlock() + continue + } + + previousState := deposit.state + previousConfirmationHeight := deposit.ConfirmationHeight + deposit.ConfirmationHeight = confirmationHeight + deposit.SetStateNoLock(Deposited) + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.SetStateNoLock(previousState) + deposit.Unlock() + return err + } + + deposit.Unlock() + + log.Infof("Reactivated deposit %v after it reappeared in "+ + "wallet view", deposit.OutPoint) + + err = m.startDepositFsm(ctx, deposit) + if err != nil { + m.removeActiveDeposit(deposit.OutPoint) + + deposit.Lock() + deposit.ConfirmationHeight = previousConfirmationHeight + deposit.SetStateNoLock(previousState) + rollbackErr := m.cfg.Store.UpdateDeposit(ctx, deposit) + deposit.Unlock() + if rollbackErr != nil { + return fmt.Errorf("unable to start deposit FSM: %w; "+ + "unable to roll back revived deposit: %v", + err, rollbackErr) + } + + return err + } } - select { - case tx := <-notifChan: - return tx.BlockHeight, nil + return nil +} - case err := <-errChan: - return 0, err +// invalidateVanishedDeposits marks Deposited outputs as replaced once lnd no +// longer reports the outpoint in multiple consecutive wallet observations. +// +// This closes the gap between wallet state and our DB state when a persisted +// deposit later disappears from the wallet view, for example because an +// unconfirmed funding transaction was replaced or because a previously +// confirmed transaction was evicted by a deep reorg. We only invalidate +// deposits that are still in the plain Deposited state. +// +// That keeps the scope narrow: in-flight states like LoopingIn already have +// their own recovery/error handling. +func (m *Manager) invalidateVanishedDeposits(ctx context.Context, + utxos []*lnwallet.Utxo) error { + + currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + currentUtxos[utxo.OutPoint] = struct{}{} + } - case <-ctx.Done(): - return 0, ctx.Err() + m.mu.Lock() + candidates := make([]*Deposit, 0, len(m.deposits)) + for outpoint, deposit := range m.deposits { + if _, ok := currentUtxos[outpoint]; ok { + delete(m.missingDeposits, outpoint) + continue + } + + deposit.Lock() + isVanishedDeposit := deposit.IsInStateNoLock(Deposited) + deposit.Unlock() + if !isVanishedDeposit { + delete(m.missingDeposits, outpoint) + continue + } + + m.missingDeposits[outpoint]++ + if m.missingDeposits[outpoint] < vanishedDepositThreshold { + log.Debugf("Waiting for another wallet observation before "+ + "marking deposit %v replaced", outpoint) + + continue + } + + delete(m.missingDeposits, outpoint) + candidates = append(candidates, deposit) } + m.mu.Unlock() + + for _, deposit := range candidates { + deposit.Lock() + if !deposit.IsInStateNoLock(Deposited) { + deposit.Unlock() + continue + } + + // Persist the replacement marker before removing the deposit from the + // active set so restarted clients and RPC consumers see the same outcome. + previousState := deposit.state + deposit.SetStateNoLock(Replaced) + err := m.cfg.Store.UpdateDeposit(ctx, deposit) + if err != nil { + deposit.SetStateNoLock(previousState) + deposit.Unlock() + return err + } + + deposit.Unlock() + + m.removeActiveDeposit(deposit.OutPoint) + + log.Infof("Marked vanished deposit %v as replaced", + deposit.OutPoint) + } + + return nil } // filterNewDeposits filters the given utxos for new deposits that we haven't @@ -508,6 +870,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 { @@ -537,6 +906,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) diff --git a/staticaddr/deposit/manager_height_test.go b/staticaddr/deposit/manager_height_test.go new file mode 100644 index 000000000..82b41ea5c --- /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 best 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 new file mode 100644 index 000000000..be66f0a26 --- /dev/null +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -0,0 +1,587 @@ +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/fsm" + "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" +) + +// expectStableBestBlock configures two stable best-block lookups. +func expectStableBestBlock(mockChainKit *MockChainKit, height int32) { + mockChainKit.On( + "GetBestBlock", mock.Anything, + ).Return(chainhash.Hash{}, height, nil).Twice() +} + +// 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) +} + +// TestReconcileConfirmedDepositUsesBestBlockHeight verifies confirmation +// heights are derived from a stable chain tip. +func TestReconcileConfirmedDepositUsesBestBlockHeight(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")) + + mockChainKit := new(MockChainKit) + expectStableBestBlock(mockChainKit, 100) + + 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, + ChainKit: mockChainKit, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + + 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) +} + +// TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit verifies that a +// single missing ListUnspent observation is reversible, but repeated misses +// still mark the deposit as replaced. +func TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit(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) + + mockStore := new(mockStore) + var updateCalls atomic.Int32 + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updateCalls.Add(1) + updatedDeposit := args.Get(1).(*Deposit) + require.True(t, updatedDeposit.IsInStateNoLock(Replaced)) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + stopChan: make(chan struct{}), + quitChan: make(chan struct{}), + } + go func() { + <-fsm.stopChan + close(fsm.quitChan) + }() + manager.activeDeposits[outpoint] = fsm + + // The first miss only increments the consecutive-miss counter. + require.NoError(t, manager.reconcileDeposits(ctx)) + require.EqualValues(t, 0, updateCalls.Load()) + require.Equal(t, Deposited, deposit.GetState()) + require.Len(t, manager.activeDeposits, 1) + + // The second consecutive miss is strong enough evidence to finalize the + // record as replaced. + require.NoError(t, manager.reconcileDeposits(ctx)) + require.EqualValues(t, 1, updateCalls.Load()) + require.Equal(t, Replaced, deposit.GetState()) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after deposit was replaced") + } +} + +// TestReconcileDepositsInvalidatesVanishedConfirmedDeposit verifies that a +// previously confirmed deposit is also marked replaced if it vanishes from the +// wallet view for multiple consecutive observations. +func TestReconcileDepositsInvalidatesVanishedConfirmedDeposit(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) + + mockStore := new(mockStore) + var updateCalls atomic.Int32 + mockStore.On( + "UpdateDeposit", mock.Anything, mock.Anything, + ).Return(nil).Run(func(args mock.Arguments) { + updateCalls.Add(1) + updatedDeposit := args.Get(1).(*Deposit) + require.True(t, updatedDeposit.IsInStateNoLock(Replaced)) + require.EqualValues( + t, 123, updatedDeposit.ConfirmationHeight, + ) + }) + + manager := NewManager(&ManagerConfig{ + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + fsm := &FSM{ + 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.EqualValues(t, 0, updateCalls.Load()) + require.Equal(t, Deposited, deposit.GetState()) + require.Len(t, manager.activeDeposits, 1) + + require.NoError(t, manager.reconcileDeposits(ctx)) + require.EqualValues(t, 1, updateCalls.Load()) + require.Equal(t, Replaced, deposit.GetState()) + require.Empty(t, manager.activeDeposits) + select { + case <-fsm.quitChan: + + case <-time.After(time.Second): + t.Fatal("fsm did not stop after confirmed deposit was replaced") + } +} + +// TestTransitionDepositsRejectsReplacedDeposit verifies that a transition which +// captured an active FSM before replacement cannot move the deposit back out of +// Replaced after the replacement path releases the deposit lock. +func TestTransitionDepositsRejectsReplacedDeposit(t *testing.T) { + ctx := context.Background() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 6, + } + + deposit := &Deposit{ + OutPoint: outpoint, + } + deposit.SetState(Replaced) + + mockStore := new(mockStore) + depositFSM := &FSM{ + cfg: &ManagerConfig{ + Store: mockStore, + }, + deposit: deposit, + } + depositFSM.StateMachine = fsm.NewStateMachineWithState( + depositFSM.DepositStatesV0(), Deposited, DefaultObserverSize, + ) + depositFSM.ActionEntryFunc = depositFSM.updateDeposit + + manager := NewManager(&ManagerConfig{}) + manager.deposits[outpoint] = deposit + manager.activeDeposits[outpoint] = depositFSM + + err := manager.TransitionDeposits( + ctx, []*Deposit{deposit}, OnLoopInInitiated, LoopingIn, + ) + require.ErrorContains(t, err, "no longer active") + require.Equal(t, Replaced, deposit.GetState()) + mockStore.AssertNotCalled( + t, "UpdateDeposit", mock.Anything, mock.Anything, + ) +} + +// TestReconcileDepositsReactivatesReappearedReplacedDeposit verifies that the +// same outpoint can be revived if lnd reports it again after being marked +// replaced. +func TestReconcileDepositsReactivatesReappearedReplacedDeposit(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(Replaced) + + 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 revive 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{Replaced, Deposited}, updateStates) +} + +// TestReconcileDepositsRollsBackRevivalOnFSMStartFailure verifies that a +// failed revival doesn't leave the store or memory saying a deposit is active +// without an FSM. +func TestReconcileDepositsRollsBackRevivalOnFSMStartFailure(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(Replaced) + + 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 revive reappeared deposits") + require.Equal(t, Replaced, deposit.GetState()) + require.Zero(t, deposit.ConfirmationHeight) + require.Empty(t, manager.activeDeposits) + require.Equal( + t, []fsm.StateType{Replaced, Deposited, Replaced}, + updateStates, + ) + require.EqualValues(t, []int64{0, 0, 0}, updateHeights) +} + +// 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 + manager.missingDeposits[oldOutpoint] = 1 + + 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]) + require.Empty(t, manager.missingDeposits) + + mockStore.AssertNotCalled( + t, "UpdateDeposit", mock.Anything, mock.Anything, + ) +} diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 0ab793021..6bb485f8e 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -215,6 +215,49 @@ func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context, args.Get(1).(chan error), args.Error(2) } +type MockChainKit struct { + mock.Mock +} + +// RawClientWithMacAuth implements lndclient.ChainKitClient for tests. +func (m *MockChainKit) RawClientWithMacAuth( + ctx context.Context) (context.Context, time.Duration, + chainrpc.ChainKitClient) { + + return ctx, 0, nil +} + +// GetBlock implements lndclient.ChainKitClient for tests. +func (m *MockChainKit) GetBlock(context.Context, chainhash.Hash) ( + *wire.MsgBlock, error) { + + panic("unexpected GetBlock call") +} + +// GetBlockHeader implements lndclient.ChainKitClient for tests. +func (m *MockChainKit) GetBlockHeader(context.Context, chainhash.Hash) ( + *wire.BlockHeader, error) { + + panic("unexpected GetBlockHeader call") +} + +// GetBestBlock returns the configured best-block mock response. +func (m *MockChainKit) GetBestBlock(ctx context.Context) ( + chainhash.Hash, int32, error) { + + args := m.Called(ctx) + + return args.Get(0).(chainhash.Hash), args.Get(1).(int32), + args.Error(2) +} + +// GetBlockHash implements lndclient.ChainKitClient for tests. +func (m *MockChainKit) GetBlockHash(context.Context, int64) ( + chainhash.Hash, error) { + + panic("unexpected GetBlockHash call") +} + // TestManager checks that the manager processes the right channel notifications // while a deposit is expiring. func TestManager(t *testing.T) { @@ -233,6 +276,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: @@ -303,6 +350,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 161b9c8efff2cae10d68ba86f29becc2071cfbc8 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 22 Jun 2026 16:53:15 +0200 Subject: [PATCH 2/7] staticaddr: expose tracked deposit availability Return static-address UTXOs only once they have active deposit records, report unconfirmed value separately, and reject unconfirmed deposits from withdrawal and channel-open paths that still require confirmed inputs. --- loopd/swapclient_server.go | 150 +++++++++----- loopd/swapclient_server_deposit_test.go | 86 ++++++++ loopd/swapclient_server_staticaddr_test.go | 225 +++++++++++++++++++++ loopd/swapclient_server_test.go | 72 +++---- staticaddr/openchannel/manager.go | 28 +++ staticaddr/openchannel/manager_test.go | 108 +++++++++- staticaddr/withdraw/manager.go | 9 + 7 files changed, 579 insertions(+), 99 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 bed6efa80..c10e32698 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -976,15 +976,24 @@ 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 { + // ListStaticAddressDeposits only filters out deposits that are no + // longer visible to the user, such as Replaced records. For a manual + // quote we additionally 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, ) @@ -1692,58 +1701,35 @@ 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()) } // 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. @@ -1790,8 +1776,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: @@ -1814,6 +1801,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.ConfirmationHeight <= 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. @@ -1838,7 +1844,8 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, var filteredDeposits []*looprpc.Deposit if len(outpoints) > 0 { f := func(d *deposit.Deposit) bool { - return slices.Contains(outpoints, d.OutPoint.String()) + return isVisibleDeposit(d) && + slices.Contains(outpoints, d.OutPoint.String()) } filteredDeposits = filter(allDeposits, f) @@ -1848,6 +1855,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context, } } else { f := func(d *deposit.Deposit) bool { + if !isVisibleDeposit(d) { + return false + } + if req.StateFilter == looprpc.DepositState_UNKNOWN_STATE { // Per default, we return deposits in all // states. @@ -1986,9 +1997,10 @@ 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 + - int64(addrParams.Expiry) - - int64(lndInfo.BlockHeight) + blocksUntilExpiry := depositBlocksUntilExpiry( + d.ConfirmationHeight, addrParams.Expiry, + int64(lndInfo.BlockHeight), + ) pd := &looprpc.Deposit{ Id: d.ID[:], @@ -2068,6 +2080,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context, if err != nil { return nil, err } + allDeposits = filterDeposits(allDeposits, isVisibleDeposit) var ( totalNumDeposits = len(allDeposits) @@ -2080,23 +2093,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.ConfirmationHeight <= 0 { + valueUnconfirmed += value + } else { + valueDeposited += value + } case deposit.Expired: valueExpired += value @@ -2246,13 +2252,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, @@ -2282,6 +2302,30 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context, type filterFunc func(deposits *deposit.Deposit) bool +// filterDeposits returns all deposits accepted by the given predicate. +func filterDeposits(deposits []*deposit.Deposit, + f filterFunc) []*deposit.Deposit { + + filtered := make([]*deposit.Deposit, 0, len(deposits)) + for _, deposit := range deposits { + if !f(deposit) { + continue + } + + filtered = append(filtered, deposit) + } + + return filtered +} + +// isVisibleDeposit returns true if a deposit should appear in normal listings. +func isVisibleDeposit(d *deposit.Deposit) bool { + // Replaced deposits are kept in the DB as history, but they should disappear + // from normal deposit listings and summary totals because the underlying + // outpoint is no longer present in the wallet and cannot be spent. + return d.GetState() != deposit.Replaced +} + func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit { var clientDeposits []*looprpc.Deposit for _, d := range deposits { 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..af13ff9e1 --- /dev/null +++ b/loopd/swapclient_server_staticaddr_test.go @@ -0,0 +1,225 @@ +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 +} + +// TestListStaticAddressDepositsHidesReplaced verifies replaced deposits are +// hidden from normal deposit listings. +func TestListStaticAddressDepositsHidesReplaced(t *testing.T) { + t.Parallel() + + replaced := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + } + replaced.SetState(deposit.Replaced) + + available := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + } + available.SetState(deposit.Deposited) + + addrMgr, lnd := newTestStaticAddressContext(t) + server := &swapClientServer{ + depositManager: newTestDepositManager(replaced, 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, + ) +} + +// TestGetStaticAddressSummaryIgnoresReplaced verifies replaced deposits are +// excluded from static address summary totals. +func TestGetStaticAddressSummaryIgnoresReplaced(t *testing.T) { + t.Parallel() + + replaced := &deposit.Deposit{ + OutPoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 3, + }, + Value: btcutil.Amount(1_000), + } + replaced.SetState(deposit.Replaced) + + 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( + replaced, 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) { + 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..a79d1950e 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1321,7 +1321,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, @@ -1376,11 +1376,11 @@ 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( @@ -1398,17 +1398,19 @@ func TestListUnspentDeposits(t *testing.T) { return deposit.NewManager(&deposit.ManagerConfig{Store: store}) } - // 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{ @@ -1421,8 +1423,8 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - // 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 +1432,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{ @@ -1461,22 +1461,20 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - 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) { + // Confirmed UTXO not present in store should be excluded. + t.Run("confirmed utxo not in store is excluded", 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}) + mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown}) // Empty store (no states for any outpoint). depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) @@ -1491,12 +1489,6 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - // 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, - ) - require.NotEmpty(t, resp.Utxos[0].StaticAddress) + require.Empty(t, resp.Utxos) }) } diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go index 2274ce506..d2937506d 100644 --- a/staticaddr/openchannel/manager.go +++ b/staticaddr/openchannel/manager.go @@ -310,6 +310,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 req.LocalFundingAmount != 0 { deposits, err = staticutil.SelectDeposits( deposits, req.LocalFundingAmount, @@ -325,6 +329,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. @@ -399,6 +411,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 f408da169..e76e6a1b1 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 6cd941496..5c8a036d7 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 105479f337e28fd1069e596443178adae4445298 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 22 Jun 2026 16:53:32 +0200 Subject: [PATCH 3/7] staticaddr/loopin: support unconfirmed deposits Allow static loop-ins to select unconfirmed deposits, persist the original selected outpoints, validate that those outpoints are still available before HTLC signing, and align autoloop expiry ordering with static loop-in selection. --- loopd/daemon.go | 1 + staticaddr/deposit/manager.go | 5 +- staticaddr/loopin/actions.go | 111 ++++++++++++++---- staticaddr/loopin/actions_test.go | 149 +++++++++++++++++++++++- staticaddr/loopin/autoloop_dp.go | 6 +- staticaddr/loopin/autoloop_dp_test.go | 22 ++++ staticaddr/loopin/interface.go | 11 ++ staticaddr/loopin/loopin.go | 2 - staticaddr/loopin/manager.go | 73 ++++++++---- staticaddr/loopin/manager_test.go | 27 +++++ staticaddr/loopin/sql_store.go | 9 +- staticaddr/loopin/sql_store_test.go | 73 ++++++++++++ staticaddr/loopin/txout_checker.go | 72 ++++++++++++ staticaddr/loopin/txout_checker_test.go | 98 ++++++++++++++++ test/lightning_client_mock.go | 4 +- test/lnd_services_mock.go | 9 ++ 16 files changed, 615 insertions(+), 57 deletions(-) create mode 100644 staticaddr/loopin/txout_checker.go create mode 100644 staticaddr/loopin/txout_checker_test.go diff --git a/loopd/daemon.go b/loopd/daemon.go index 4d568f65f..5abc78bd2 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -700,6 +700,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error { Store: staticAddressLoopInStore, WalletKit: d.lnd.WalletKit, ChainNotifier: d.lnd.ChainNotifier, + TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client), NotificationManager: notificationManager, ChainParams: d.lnd.ChainParams, Signer: d.lnd.Signer, diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index eb9e24b33..9403d28a9 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -18,9 +18,8 @@ import ( ) const ( - // MinConfs is the minimum number of confirmations we require for a - // deposit to be considered available for loop-ins, coop-spends and - // timeouts. + // MinConfs is the legacy minimum confirmation target deposits had to + // reach before they were considered ready to be used for swaps. MinConfs = 6 // MaxConfs is unset since we don't require a max number of diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index ccf86b939..af06dd453 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" @@ -324,7 +325,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 } @@ -340,6 +341,68 @@ 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 + } +} + +// 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. @@ -348,6 +411,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(ctx) + + return f.HandleError(err) + } + f.loopIn.AddressParams, err = f.cfg.AddressManager.GetStaticAddressParameters(ctx) @@ -717,32 +792,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..29806db80 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -54,10 +54,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{ @@ -270,6 +270,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) { @@ -541,6 +668,24 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, return r.err } +type testTxOutChecker struct { + txOut *wire.TxOut + err error + + outpoints []wire.OutPoint + includeMempool []bool +} + +// GetTxOut records lookup parameters and returns the configured result. +func (t *testTxOutChecker) GetTxOut(_ context.Context, + outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) { + + t.outpoints = append(t.outpoints, outpoint) + t.includeMempool = append(t.includeMempool, includeMempool) + + return t.txOut, t.err +} + // initHtlcTestServer lets InitHtlcAction tests inject a deterministic server // response without standing up the full gRPC client. type initHtlcTestServer struct { diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go index c605c43ef..d8abde50e 100644 --- a/staticaddr/loopin/autoloop_dp.go +++ b/staticaddr/loopin/autoloop_dp.go @@ -246,8 +246,10 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, continue } - residualLife := candidateDeposit.ConfirmationHeight + - int64(csvExpiry) - int64(blockHeight) + residualLife := int64(blocksUntilDepositExpiry( + uint32(candidateDeposit.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. 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/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..1501786d5 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -78,6 +79,10 @@ type Config struct { // blocks. ChainNotifier lndclient.ChainNotifierClient + // TxOutChecker checks whether selected deposit outpoints are still + // available before we sign an HTLC transaction for them. + TxOutChecker TxOutChecker + // Signer is the signer client that is used to sign transactions. Signer lndclient.SignerClient @@ -754,8 +759,10 @@ func (m *Manager) initiateLoopIn(ctx context.Context, } swap := &StaticAddressLoopIn{ - SelectedAmount: req.SelectedAmount, - DepositOutpoints: selectedOutpoints, + SelectedAmount: req.SelectedAmount, + DepositOutpoints: append( + []string(nil), selectedOutpoints..., + ), Deposits: selectedDeposits, Label: req.Label, Initiator: req.Initiator, @@ -845,11 +852,11 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn, return swaps, nil } -// 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) { @@ -870,14 +877,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 { + iConfirmed := deposits[i].ConfirmationHeight > 0 + jConfirmed := deposits[j].ConfirmationHeight > 0 + if iConfirmed != jConfirmed { + return iConfirmed + } + if deposits[i].Value == deposits[j].Value { - iExp := uint32(deposits[i].ConfirmationHeight) + - csvExpiry - blockHeight - jExp := uint32(deposits[j].ConfirmationHeight) + - csvExpiry - blockHeight + iExp := blocksUntilDepositExpiry( + uint32(deposits[i].ConfirmationHeight), + blockHeight, csvExpiry, + ) + jExp := blocksUntilDepositExpiry( + uint32(deposits[j].ConfirmationHeight), + blockHeight, csvExpiry, + ) return iExp < jExp } @@ -909,20 +927,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 85178a05b..585497473 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}, @@ -298,6 +319,12 @@ func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { require.ErrorContains(t, err, depOutpoint) } +// 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/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) +} 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 +} diff --git a/test/lightning_client_mock.go b/test/lightning_client_mock.go index 9a9b9047a..a7eb556e8 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 164bea181..1076a6712 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -210,6 +210,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 a81be3e430364eefda4c0591805faef16169e583 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 22 Jun 2026 16:53:47 +0200 Subject: [PATCH 4/7] staticaddr/loopin: wait for risk decisions Subscribe to static loop-in confirmation-risk notifications, start the payment deadline only after server acceptance or legacy confirmation fallback, and cancel the swap invoice when the server rejects the risk wait. --- notifications/manager.go | 185 ++++++++ notifications/manager_test.go | 254 ++++++++++ staticaddr/loopin/actions.go | 193 ++++++-- staticaddr/loopin/actions_test.go | 748 ++++++++++++++++++++++++++++++ staticaddr/loopin/interface.go | 14 + staticaddr/loopin/loopin.go | 20 +- 6 files changed, 1382 insertions(+), 32 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 af06dd453..7d07f7127 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -370,6 +371,61 @@ 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 + } + + d.Lock() + outpoint := d.OutPoint.String() + confirmationHeight := d.ConfirmationHeight + d.Unlock() + + if _, ok := outpoints[outpoint]; !ok { + continue + } + + confirmations[outpoint] = confirmationHeight + } + + return confirmations +} + +// 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 +} + // originalDepositOutpointUnavailable checks the original selected deposit // outpoints against the chain backend's UTXO view. func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( @@ -418,7 +474,7 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context, if outpointUnavailable { err = errors.New("original deposit outpoint no longer available") f.Warnf("%v, canceling swap invoice", err) - f.cancelSwapInvoice(ctx) + f.cancelSwapInvoice() return f.HandleError(err) } @@ -634,7 +690,28 @@ 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() htlcConfirmed := false + depositsUnlocked := false invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) if err != nil { @@ -644,30 +721,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 set the timeout to 0 to cancel the - // invoice and unlock the deposits immediately. - 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() @@ -724,6 +805,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 @@ -734,9 +817,59 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if err != nil { f.Errorf("unable to unlock deposits after "+ "payment deadline: %v", err) + continue + } + depositsUnlocked = true + + 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: + depositConfirmationHeights := + selectedDepositConfirmationHeights(f.loopIn) + + if legacyMinConfsReached( + f.loopIn.DepositOutpoints, + depositConfirmationHeights, currentHeight, + ) { + + 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 @@ -762,13 +895,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if !htlcConfirmed { f.Infof("swap timed out, htlc not confirmed") - // 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. + if !depositsUnlocked { + err = f.unlockDeposits(ctx) + if err != nil { + return f.HandleError(err) + } + } + return OnSwapTimedOut } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 29806db80..8e3907f65 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -270,6 +270,719 @@ 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, + ), + } + + 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) + + 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()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, 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) + + 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.OnError, 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 +// notification manager is available. +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{}, + 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.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager +// verifies that old servers that do not send risk notifications still get the +// legacy payment deadline even when the notification manager is configured. +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{}, + 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.OnError, 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) { @@ -652,6 +1365,9 @@ type recordingDepositManager struct { err error transitions []depositTransition + + events []fsm.EventType + states []fsm.StateType } // TransitionDeposits records the transition and returns the configured error. @@ -664,10 +1380,42 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, event: event, state: state, }) + r.events = append(r.events, event) + r.states = append(r.states, state) 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 +} + +// 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 6197d733b..f96d08e5b 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -122,4 +122,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 } 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 d8d4a5667a7002dbff5cdf3f5f2d32f937402e7b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 22 Jun 2026 16:54:04 +0200 Subject: [PATCH 5/7] staticaddr/loopin: persist risk decisions Record server confirmation-risk decisions durably, wire notification persistence through loopd, recover payment-deadline timers after restart, and keep notification fanout cache state deduplicated 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 | 196 +++++--- notifications/manager_test.go | 106 ++++- staticaddr/loopin/actions.go | 104 ++++- staticaddr/loopin/actions_test.go | 430 ++++++++++++++++++ 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, 1019 insertions(+), 166 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 5abc78bd2..5aeea5546 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) @@ -663,12 +683,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 a79d1950e..c28b4d1c7 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -535,6 +535,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..8846299ab 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 @@ -135,8 +142,8 @@ func NewManager(cfg *Config) *Manager { 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 +252,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 { @@ -525,7 +545,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 +555,69 @@ 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), notifySubscriber func(subscriber)) { + + decision := staticLoopInRiskDecisionName(accepted) + + 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) + } + } + + m.Lock() + defer m.Unlock() + + if hasSwapHash { + cacheDecision(swapHash) + } + + 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 +657,55 @@ 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) { + m.staticLoopInRiskAccepted[swapHash] = + riskAcceptedNtfn + 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) { + m.staticLoopInRiskRejected[swapHash] = + riskRejectedNtfn + 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..9da24a01a 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,91 @@ 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_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a // notification for one swap does not occupy another swap's subscriber channel. func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) { @@ -603,6 +690,7 @@ func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) { swapHash := lntypes.Hash{0x06, 0x07} mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskAccepted{ @@ -643,6 +731,7 @@ func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) { subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash) mgr.handleNotification( + t.Context(), &swapserverrpc.SubscribeNotificationsResponse{ Notification: &swapserverrpc. SubscribeNotificationsResponse_StaticLoopInRiskRejected{ @@ -689,6 +778,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 7d07f7127..2e48843f5 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -735,12 +735,18 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } }() - startPaymentDeadline := func(reason string) { + startPaymentDeadline := func(reason string, startedAt time.Time) { if deadlineStarted || invoice.State == invoices.ContractCanceled { return } timeout := f.loopIn.PaymentTimeoutDuration() + if !startedAt.IsZero() { + timeout -= time.Since(startedAt) + if timeout < 0 { + timeout = 0 + } + } f.Infof("starting payment deadline after %s", reason) deadlineTimer = time.NewTimer(timeout) @@ -766,6 +772,84 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.cancelSwapInvoice() } + 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 + } + + switch f.loopIn.ConfirmationRiskDecision { + case ConfirmationRiskDecisionAccepted: + startPaymentDeadline( + "recovered risk accepted notification", + f.loopIn.ConfirmationRiskDecisionTime, + ) + + case ConfirmationRiskDecisionRejected: + cancelInvoiceSubscription() + f.cancelSwapInvoice() + + return f.HandleError(errors.New( + "server rejected confirmation risk wait", + )) + } + for { select { case <-htlcConfChan: @@ -834,7 +918,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 { @@ -851,6 +944,12 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, cancelInvoiceSubscription() f.cancelSwapInvoice() + decisionTime := riskDecisionTime( + ConfirmationRiskDecisionRejected, + ) + f.loopIn.ConfirmationRiskDecision = + ConfirmationRiskDecisionRejected + f.loopIn.ConfirmationRiskDecisionTime = decisionTime return f.HandleError(errors.New( "server rejected confirmation risk wait", @@ -867,6 +966,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, startPaymentDeadline( "legacy confirmation fallback", + time.Time{}, ) } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 8e3907f65..4ad922859 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -383,6 +383,234 @@ func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(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.OnError, 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.OnError, 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) { @@ -479,6 +707,181 @@ func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { } } +// 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, + }) + + 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, + } + + 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()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxRecoversRejectedRiskDecision verifies that a +// persisted risk rejection is terminal after restart without waiting for a +// replayed server notification. +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: 2_000, + 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, + }) + + 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, + } + + 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) + + 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 @@ -1386,6 +1789,33 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, 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 f96d08e5b..89de19a2e 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -88,6 +88,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 6c1860bdb..e77384bf5 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 585497473..7cd1f4df8 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -460,6 +460,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 8f3f5f43bd7393ad70f403d3992e47b3bb6aad30 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 19 Jun 2026 14:40:07 +0200 Subject: [PATCH 6/7] cmd/loop: warn for low-confirmation static deposits Low-confirmation static deposits can be accepted by the server only after asynchronous confirmation-risk policy permits them, so a CLI user may see a delay after confirming the swap. The warning must also reflect the deposits selected by automatic coin selection, not just manually provided outpoints. Add a static loop-in warning for selected deposits below the conservative six-confirmation threshold, mirror automatic selection for warning output, and cover manual and auto-selected warning cases in CLI tests. --- cmd/loop/staticaddr.go | 187 +++++++++++++++++++++++++++++- cmd/loop/staticaddr_test.go | 220 ++++++++++++++++++++++++++++++++++++ 2 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 cmd/loop/staticaddr_test.go diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index f4f72d7fe..168479e06 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,14 @@ 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("no deposited outputs available") + } - return errors.New(errString) + summary, err := client.GetStaticAddressSummary( + ctx, &looprpc.StaticAddressSummaryRequest{}, + ) + if err != nil { + return err } var depositOutpoints []string @@ -614,6 +620,21 @@ 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. + 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 da5253e0f15d9f1a3c11c79e764787c41482dbd4 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 19 Jun 2026 14:52:52 +0200 Subject: [PATCH 7/7] cmd/loop: update static loop-in replay fixtures Static loop-in replay sessions changed once low-confirmation warnings and payment-timeout prompts were added, and quote validation should happen before the summary request affects prompt order. The fixtures need to reflect the new CLI interaction sequence. Move the static address summary request after quote validation, add replay fixtures for static loop-in warning prompts, and refresh payment-timeout and fee replay variants. --- cmd/loop/staticaddr.go | 14 ++++----- .../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 +++++++++++++++++++ 6 files changed, 162 insertions(+), 7 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 168479e06..c973a0028 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -559,13 +559,6 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { return errors.New("no deposited outputs available") } - summary, err := client.GetStaticAddressSummary( - ctx, &looprpc.StaticAddressSummaryRequest{}, - ) - if err != nil { - return err - } - var depositOutpoints []string switch { case isAllSelected && isUtxoSelected: @@ -623,6 +616,13 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { // 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, 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",