From fec288dda8b35d176531b57f6e90d8767f41d297 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:31:53 +0100 Subject: [PATCH 1/8] deposit: track block height and derive confirmation from wallet UTXOs Replace the blocking RegisterConfirmationsNtfn-based getBlockHeight with a lightweight confirmationHeightForUtxo that derives the first confirmation height from the wallet UTXO confirmation count and the currently tracked block height. This removes the MinConfs gating from reconcileDeposits so that mempool deposits are detected immediately with ConfirmationHeight=0. Add updateDepositConfirmations to backfill confirmation heights once previously-unconfirmed deposits get mined. The manager now stores the current block height via an atomic and reconciles deposits on every new block. --- staticaddr/deposit/manager.go | 119 +++++++++++++++++----- staticaddr/deposit/manager_height_test.go | 53 ++++++++++ 2 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 staticaddr/deposit/manager_height_test.go diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index af8820302..e10e587a0 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" @@ -77,6 +78,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. @@ -98,6 +102,17 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + select { + case height := <-newBlockChan: + m.currentHeight.Store(uint32(height)) + + case err = <-newBlockErrChan: + return err + + case <-ctx.Done(): + return ctx.Err() + } + // Recover previous deposits and static address parameters from the DB. err = m.recoverDeposits(ctx) if err != nil { @@ -123,6 +138,13 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { for { select { case height := <-newBlockChan: + m.currentHeight.Store(uint32(height)) + + err := m.reconcileDeposits(ctx) + if err != nil { + log.Errorf("unable to reconcile deposits: %v", err) + } + // Inform all active deposits about a new block arrival. m.mu.Lock() activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) @@ -207,8 +229,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...") @@ -239,12 +263,18 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { log.Tracef("Reconciling new deposits...") utxos, err := m.cfg.AddressManager.ListUnspent( - ctx, MinConfs, MaxConfs, + ctx, 0, MaxConfs, ) if err != nil { return fmt.Errorf("unable to list new deposits: %w", err) } + err = m.updateDepositConfirmations(ctx, utxos) + if err != nil { + return fmt.Errorf("unable to update deposit "+ + "confirmations: %w", err) + } + newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") @@ -274,7 +304,7 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error { func (m *Manager) createNewDeposit(ctx context.Context, utxo *lnwallet.Utxo) (*Deposit, error) { - blockHeight, err := m.getBlockHeight(ctx, utxo) + confirmationHeight, err := m.confirmationHeightForUtxo(utxo) if err != nil { return nil, err } @@ -302,7 +332,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 +348,70 @@ 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 UTXO. +// Unconfirmed UTXOs return 0. +func (m *Manager) confirmationHeightForUtxo(utxo *lnwallet.Utxo) (int64, + error) { - addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters( - ctx, - ) - if err != nil { - return 0, fmt.Errorf("couldn't get confirmation height for "+ - "deposit, %w", err) + if utxo.Confirmations <= 0 { + return 0, nil } - notifChan, errChan, err := - m.cfg.ChainNotifier.RegisterConfirmationsNtfn( - ctx, &utxo.OutPoint.Hash, addressParams.PkScript, - MinConfs, addressParams.InitiationHeight, - ) - if err != nil { - return 0, err + currentHeight := m.currentHeight.Load() + if currentHeight == 0 { + return 0, fmt.Errorf("current block height unknown") } - select { - case tx := <-notifChan: - return tx.BlockHeight, nil + numConfs := uint32(utxo.Confirmations) + if numConfs > currentHeight+1 { + return 0, fmt.Errorf("invalid confirmation count %d at "+ + "height %d", utxo.Confirmations, currentHeight) + } - case err := <-errChan: - return 0, err + return int64(currentHeight-numConfs) + 1, nil +} - case <-ctx.Done(): - return 0, ctx.Err() +// updateDepositConfirmations backfills first confirmation heights for deposits +// that were previously detected unconfirmed. +func (m *Manager) updateDepositConfirmations(ctx context.Context, + utxos []*lnwallet.Utxo) error { + + for _, utxo := range utxos { + if utxo.Confirmations <= 0 { + continue + } + + m.mu.Lock() + deposit, ok := m.deposits[utxo.OutPoint] + m.mu.Unlock() + if !ok { + continue + } + + deposit.Lock() + if deposit.ConfirmationHeight > 0 { + deposit.Unlock() + continue + } + + confirmationHeight, err := m.confirmationHeightForUtxo( + utxo, + ) + if err != nil { + deposit.Unlock() + return err + } + + deposit.ConfirmationHeight = confirmationHeight + + err = m.cfg.Store.UpdateDeposit(ctx, deposit) + deposit.Unlock() + if err != nil { + return err + } } + + return nil } // filterNewDeposits filters the given utxos for new deposits that we haven't diff --git a/staticaddr/deposit/manager_height_test.go b/staticaddr/deposit/manager_height_test.go new file mode 100644 index 000000000..1fae656fb --- /dev/null +++ b/staticaddr/deposit/manager_height_test.go @@ -0,0 +1,53 @@ +package deposit + +import ( + "testing" + + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +// TestConfirmationHeightForUtxo verifies confirmation height derivation from +// wallet UTXO confirmation counts. +func TestConfirmationHeightForUtxo(t *testing.T) { + manager := NewManager(&ManagerConfig{}) + manager.currentHeight.Store(100) + + tests := []struct { + name string + confirmations int64 + expected int64 + }{ + { + name: "unconfirmed", + confirmations: 0, + expected: 0, + }, + { + name: "single confirmation", + confirmations: 1, + expected: 100, + }, + { + name: "three confirmations", + confirmations: 3, + expected: 98, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + height, err := manager.confirmationHeightForUtxo( + &lnwallet.Utxo{ + Confirmations: test.confirmations, + OutPoint: wire.OutPoint{ + Index: 1, + }, + }, + ) + require.NoError(t, err) + require.Equal(t, test.expected, height) + }) + } +} From 663bda0cfe9978f962a8135e37fce17f1d40f757 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:32:10 +0100 Subject: [PATCH 2/8] deposit: guard unconfirmed deposits against expiry Unconfirmed deposits (ConfirmationHeight <= 0) cannot have started their CSV timer, so IsExpired now returns false for them. Update the Deposited state and OnStart event documentation to reflect that deposits are detected from the mempool rather than after reaching a confirmation threshold. --- staticaddr/deposit/deposit.go | 4 ++++ staticaddr/deposit/deposit_test.go | 15 +++++++++++++++ staticaddr/deposit/fsm.go | 8 ++++---- 3 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 staticaddr/deposit/deposit_test.go diff --git a/staticaddr/deposit/deposit.go b/staticaddr/deposit/deposit.go index 4cb64bc95..1685fb763 100644 --- a/staticaddr/deposit/deposit.go +++ b/staticaddr/deposit/deposit.go @@ -78,6 +78,10 @@ func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool { d.Lock() defer d.Unlock() + if d.ConfirmationHeight <= 0 { + return false + } + return currentHeight >= uint32(d.ConfirmationHeight)+expiry } diff --git a/staticaddr/deposit/deposit_test.go b/staticaddr/deposit/deposit_test.go new file mode 100644 index 000000000..ddfef6df3 --- /dev/null +++ b/staticaddr/deposit/deposit_test.go @@ -0,0 +1,15 @@ +package deposit + +import "testing" + +// TestIsExpiredUnconfirmed checks that unconfirmed deposits don't start their +// expiry timer. +func TestIsExpiredUnconfirmed(t *testing.T) { + deposit := &Deposit{ + ConfirmationHeight: 0, + } + + if deposit.IsExpired(500, 100) { + t.Fatal("unconfirmed deposit should not be expired") + } +} diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 197bf2ee3..dc2b696e6 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -42,8 +42,8 @@ var ( // States. var ( - // Deposited signals that funds at a static address have reached the - // confirmation height. + // Deposited signals that funds at a static address have been detected + // and are available to the client. Deposited = fsm.StateType("Deposited") // Withdrawing signals that the withdrawal transaction has been @@ -93,8 +93,8 @@ var ( // Events. var ( // OnStart is sent to the fsm once the deposit outpoint has been - // sufficiently confirmed. It transitions the fsm into the Deposited - // state from where we can trigger a withdrawal, a loopin or an expiry. + // detected. It transitions the fsm into the Deposited state from where + // we can trigger a withdrawal, a loopin or an expiry. OnStart = fsm.EventType("OnStart") // OnWithdrawInitiated is sent to the fsm when a withdrawal has been From 0efbd9f2849cd53fb5a8ffb4c07f0c3f13ef42c5 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:32:50 +0100 Subject: [PATCH 3/8] loopd: simplify ListUnspentDeposits to use deposit state Remove the MinConfs-based bifurcation from ListUnspentDeposits. All wallet UTXOs are now checked against the deposit store: known deposits in the Deposited state are available, unknown outpoints are new deposits and also available, and all other known states are filtered out. The mock deposit store now returns ErrDepositNotFound for missing outpoints to match the real store behavior, and tests are updated to verify availability by deposit state rather than confirmation depth. --- loopd/swapclient_server.go | 41 +++++++++--------------- loopd/swapclient_server_test.go | 55 +++++++++++++++++---------------- 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 62187d3e5..829206fbe 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1662,54 +1662,43 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, // not spendable because they already have been used but not yet spent // by the server. We filter out such deposits here. var ( - outpoints []string - isUnspent = make(map[wire.OutPoint]struct{}) + outpoints []string + isUnspent = make(map[wire.OutPoint]struct{}) + knownUtxos = 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()) + knownUtxos[utxo.OutPoint] = struct{}{} } // 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 } + + knownDeposits := make(map[wire.OutPoint]struct{}, len(deposits)) 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. + knownDeposits[d.OutPoint] = struct{}{} 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{}{} + // Any wallet outpoints that are unknown to the deposit store are new + // deposits and therefore still available. + for op := range knownUtxos { + if _, ok := knownDeposits[op]; !ok { + isUnspent[op] = struct{}{} + } } // Prepare the list of unspent deposits for the rpc response. diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index a3f29443f..e3ed8cde0 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -1002,7 +1002,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, error) { @@ -1051,11 +1051,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( @@ -1073,17 +1073,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", + // Unknown deposits are available, Deposited is available and known + // non-Deposited states are excluded. + t.Run("unknown and Deposited included, locked states excluded", 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{ @@ -1096,7 +1098,7 @@ func TestListUnspentDeposits(t *testing.T) { ) require.NoError(t, err) - // Expect utxoBelow and utxoAt only. + // Expect the unknown utxo and the Deposited utxo only. require.Len(t, resp.Utxos, 2) got := map[string]struct{}{} for _, u := range resp.Utxos { @@ -1105,25 +1107,25 @@ func TestListUnspentDeposits(t *testing.T) { // same across utxos. require.NotEmpty(t, u.StaticAddress) } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAt.OutPoint.String()] + _, ok1 := got[utxoUnknown.OutPoint.String()] + _, ok2 := got[utxoDeposited.OutPoint.String()] require.True(t, ok1) require.True(t, ok2) }) - // 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{ @@ -1141,8 +1143,8 @@ func TestListUnspentDeposits(t *testing.T) { for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} } - _, ok1 := got[utxoBelow.OutPoint.String()] - _, ok2 := got[utxoAbove1.OutPoint.String()] + _, ok1 := got[utxoUnknown.OutPoint.String()] + _, ok2 := got[utxoDeposited.OutPoint.String()] require.True(t, ok1) require.True(t, ok2) }) @@ -1151,7 +1153,7 @@ func TestListUnspentDeposits(t *testing.T) { t.Run("confirmed utxo not in store is included", func(t *testing.T) { // Only return a confirmed UTXO from lnd and make sure the // deposit manager/store doesn't know about it. - mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2}) + mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown}) // Empty store (no states for any outpoint). depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) @@ -1170,7 +1172,8 @@ func TestListUnspentDeposits(t *testing.T) { // doesn't exist in the store yet. require.Len(t, resp.Utxos, 1) require.Equal( - t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint, + t, utxoConfirmedUnknown.OutPoint.String(), + resp.Utxos[0].Outpoint, ) require.NotEmpty(t, resp.Utxos[0].StaticAddress) }) From 4139d9ce6cd5180492598c597bc7d13682c7224f Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:33:57 +0100 Subject: [PATCH 4/8] loopd: handle unconfirmed deposits in RPC responses Extract depositBlocksUntilExpiry helper that returns the full CSV value for unconfirmed deposits (ConfirmationHeight <= 0) since their CSV has not started. Use it in ListStaticAddressSwaps and populateBlocksUntilExpiry. In GetStaticAddressSummary, derive the unconfirmed value from deposits in the Deposited state with no confirmation height instead of issuing a separate wallet ListUnspent call. --- loopd/swapclient_server.go | 62 +++++++++++++++++-------- loopd/swapclient_server_deposit_test.go | 21 +++++++++ 2 files changed, 64 insertions(+), 19 deletions(-) create mode 100644 loopd/swapclient_server_deposit_test.go diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 829206fbe..1768f74a6 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1769,6 +1769,22 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context, }, err } +// confirmedDeposits filters the given deposits and returns only those that have +// a positive confirmation height, i.e. deposits that have been confirmed +// on-chain. +func confirmedDeposits(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 +} + // ListStaticAddressDeposits returns a list of all sufficiently confirmed // deposits behind the static address and displays properties like value, // state or blocks til expiry. @@ -1937,9 +1953,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[:], @@ -2000,23 +2017,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 @@ -2159,13 +2169,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context, return err } for i := range len(deposits) { - deposits[i].BlocksUntilExpiry = - deposits[i].ConfirmationHeight + - int64(params.Expiry) - bestBlockHeight + deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry( + deposits[i].ConfirmationHeight, params.Expiry, + bestBlockHeight, + ) } return nil } +// depositBlocksUntilExpiry returns the remaining blocks until a deposit +// expires. Unconfirmed deposits return the full CSV value because the timeout +// has not started yet. +func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32, + bestBlockHeight int64) int64 { + + if confirmationHeight <= 0 { + return int64(expiry) + } + + return confirmationHeight + int64(expiry) - bestBlockHeight +} + // StaticOpenChannel initiates an open channel request using static address // deposits. func (s *swapClientServer) StaticOpenChannel(ctx context.Context, diff --git a/loopd/swapclient_server_deposit_test.go b/loopd/swapclient_server_deposit_test.go new file mode 100644 index 000000000..bded2da99 --- /dev/null +++ b/loopd/swapclient_server_deposit_test.go @@ -0,0 +1,21 @@ +package loopd + +import "testing" + +// TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for +// confirmed and unconfirmed deposits. +func TestDepositBlocksUntilExpiry(t *testing.T) { + t.Run("unconfirmed", func(t *testing.T) { + if blocks := depositBlocksUntilExpiry(0, 144, 500); blocks != 144 { + t.Fatalf("expected 144 blocks for unconfirmed deposit, got %d", + blocks) + } + }) + + t.Run("confirmed", func(t *testing.T) { + if blocks := depositBlocksUntilExpiry(450, 144, 500); blocks != 94 { + t.Fatalf("expected 94 blocks until expiry, got %d", + blocks) + } + }) +} From 833e48d81148b4133df086dde3cbcdfd9f35ea40 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:34:13 +0100 Subject: [PATCH 5/8] loopin: handle unconfirmed deposits in swap selection Unconfirmed deposits (ConfirmationHeight == 0) are considered swappable because their CSV timeout has not started yet. Extract blocksUntilDepositExpiry helper that returns MaxUint32 for unconfirmed deposits and use it in SelectDeposits sorting and IsSwappable. --- staticaddr/deposit/manager_test.go | 4 +++ staticaddr/loopin/manager.go | 42 +++++++++++++++++++++--------- staticaddr/loopin/manager_test.go | 6 +++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 659238ac9..ab2a1918b 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -240,6 +240,10 @@ func TestManager(t *testing.T) { require.NoError(t, testContext.manager.Run(ctx, initChan)) }() + // Send an initial block so the manager can proceed past its startup + // block wait. + blockChan <- int32(defaultDepositConfirmations) + // Ensure that the manager has been initialized. <-initChan diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 444ab5856..f886bdd82 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -879,10 +880,14 @@ func SelectDeposits(targetAmount btcutil.Amount, // blocks-until-expiry in ascending order. sort.Slice(deposits, func(i, j int) bool { if deposits[i].Value == deposits[j].Value { - iExp := uint32(deposits[i].ConfirmationHeight) + - 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 } @@ -914,20 +919,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 +} + +// 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 { - // The htlc expiry height is the current height plus the htlc - // cltv delta. - htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta + if confirmationHeight == 0 { + return math.MaxUint32 + } - // Ensure that the deposit doesn't expire before the htlc. - if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta { - return false + 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 d908a9e16..4e00b90f2 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -176,6 +176,12 @@ func TestSelectDeposits(t *testing.T) { } } +// 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 { byOutpoint map[string]*deposit.Deposit From a7a3319711b7dd55ac7c977c98794f9ad9d83294 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:34:26 +0100 Subject: [PATCH 6/8] staticaddr: guard channel open and withdraw against unconfirmed deposits Now that Deposited includes mempool outputs, channel opens and withdrawals must explicitly reject unconfirmed deposits (ConfirmationHeight <= 0) since both operations require confirmed inputs. --- loopd/swapclient_server.go | 2 +- loopd/swapclient_server_deposit_test.go | 27 +++++- staticaddr/openchannel/manager.go | 28 ++++++ staticaddr/openchannel/manager_test.go | 108 ++++++++++++++++++++++-- staticaddr/withdraw/manager.go | 9 ++ 5 files changed, 166 insertions(+), 8 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 1768f74a6..ab6a3441c 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1745,7 +1745,7 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context, return nil, err } - for _, d := range deposits { + for _, d := range confirmedDeposits(deposits) { outpoints = append(outpoints, d.OutPoint) } diff --git a/loopd/swapclient_server_deposit_test.go b/loopd/swapclient_server_deposit_test.go index bded2da99..b49906241 100644 --- a/loopd/swapclient_server_deposit_test.go +++ b/loopd/swapclient_server_deposit_test.go @@ -1,6 +1,10 @@ package loopd -import "testing" +import ( + "testing" + + "github.com/lightninglabs/loop/staticaddr/deposit" +) // TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for // confirmed and unconfirmed deposits. @@ -19,3 +23,24 @@ func TestDepositBlocksUntilExpiry(t *testing.T) { } }) } + +// TestConfirmedDeposits checks that helpers for bulk operations only keep +// deposits that can actually be spent on-chain. +func TestConfirmedDeposits(t *testing.T) { + t.Run("filters unconfirmed", func(t *testing.T) { + deposits := []*deposit.Deposit{ + {}, + { + ConfirmationHeight: 123, + }, + } + + filtered := confirmedDeposits(deposits) + if len(filtered) != 1 { + t.Fatalf("expected 1 confirmed deposit, got %d", len(filtered)) + } + if filtered[0].ConfirmationHeight != 123 { + t.Fatal("expected confirmed deposit to remain") + } + }) +} 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 99fddd267..f43986881 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 87afcde02e7f1abcd41c4f456f2633d075cbb986 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Tue, 24 Mar 2026 17:34:57 +0100 Subject: [PATCH 7/8] cmd/loop: simplify no-deposits error and remove deposit.MinConfs Update the loop-in CLI error message to no longer reference a minimum confirmation count now that mempool deposits are surfaced immediately. Remove the unused MinConfs constant from the deposit package. --- cmd/loop/staticaddr.go | 7 +------ staticaddr/deposit/manager.go | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index fc36597e4..683cc5496 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -7,7 +7,6 @@ import ( "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" lndcommands "github.com/lightningnetwork/lnd/cmd/commands" @@ -553,11 +552,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { allDeposits := depositList.FilteredDeposits if len(allDeposits) == 0 { - errString := fmt.Sprintf("no confirmed deposits available, "+ - "deposits need at least %v confirmations", - deposit.MinConfs) - - return errors.New(errString) + return errors.New("no deposited outputs available") } var depositOutpoints []string diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index e10e587a0..81cc41620 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -18,11 +18,6 @@ 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 = 6 - // MaxConfs is unset since we don't require a max number of // confirmations for deposits. MaxConfs = 0 From f61d02f8e200f1053f34e804a72730e31b68d034 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 26 Mar 2026 14:34:08 +0100 Subject: [PATCH 8/8] cmd/loop: warn user about low-confirmation deposits before loop-in Display a warning when selected deposits have fewer than 6 confirmations, since the swap payment for those won't be received immediately. Works for both manually selected and auto-selected deposits by deriving confirmation count from the CSV expiry and blocks-until-expiry fields. --- cmd/loop/staticaddr.go | 97 +++++++++++++++++++++++++++++++++++++ cmd/loop/staticaddr_test.go | 50 +++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 cmd/loop/staticaddr_test.go diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index 683cc5496..506e9d855 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" @@ -555,6 +556,13 @@ 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: @@ -609,6 +617,22 @@ 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 := depositOutpoints + if autoSelectDepositsForQuote { + // When auto-selecting, any deposit could be chosen. + depositsToCheck = depositsToOutpoints(allDeposits) + } + 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 { @@ -664,6 +688,79 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string { return outpoints } +// minImmediateConfs is the minimum number of confirmations a deposit needs +// for the swap payment to be executed immediately. +const minImmediateConfs = 6 + +// lowConfDepositWarning checks the selected deposits for fewer than 6 +// confirmations and returns a warning string if any are found. The swap +// payment for such deposits won't be received immediately. +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 >= minImmediateConfs { + 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 have fewer than %d "+ + "confirmations:\n%s\n"+ + "The swap payment for these deposits may not be "+ + "received immediately.\nOnly deposits with %d or "+ + "more confirmations are executed immediately.\n", + minImmediateConfs, + strings.Join(lowConfEntries, "\n"), + minImmediateConfs, + ) +} + 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..7a7802914 --- /dev/null +++ b/cmd/loop/staticaddr_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "strings" + "testing" + + "github.com/lightninglabs/loop/looprpc" + "github.com/stretchr/testify/require" +) + +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") +} + +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, "fewer than 6 confirmations")) +}