staticaddr: apply confirmation policy by flow

Allow static loop-ins to select unconfirmed deposits because their CSV timeout
has not started yet, while still preferring confirmed outputs during automatic
selection.

Keep confirmed-input requirements for channel opens and withdrawals now that
Deposited includes mempool outputs. Filter unconfirmed deposits out of automatic
selection for those flows and fail manual requests that reference them, so the
client does not build PSBTs or withdrawal attempts with unusable inputs.

Treat deposit.MinConfs as the legacy readiness threshold rather than the single
source of truth for all flows. Loop-in readiness is now governed by server
confirmation-risk policy, while withdrawals and channel opens keep their
confirmed-input checks.
This commit is contained in:
Slyghtning 2026-04-27 11:23:15 +02:00
parent d561b74216
commit 3a1429a6a0
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
9 changed files with 301 additions and 45 deletions

View file

@ -1788,8 +1788,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:
@ -1812,20 +1813,23 @@ 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))
// 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 {
continue
return nil, fmt.Errorf("can't withdraw all deposits while " +
"some deposits are unconfirmed")
}
confirmed = append(confirmed, d)
outpoints = append(outpoints, d.OutPoint)
}
return confirmed
return outpoints, nil
}
// ListStaticAddressDeposits returns a list of all sufficiently confirmed

View file

@ -1,6 +1,12 @@
package loopd
import "testing"
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.
@ -19,3 +25,62 @@ func TestDepositBlocksUntilExpiry(t *testing.T) {
}
})
}
// TestWithdrawAllDepositOutpoints checks `all` withdrawal handling for
// confirmed and unconfirmed deposits.
func TestWithdrawAllDepositOutpoints(t *testing.T) {
t.Run("rejects unconfirmed", func(t *testing.T) {
deposits := []*deposit.Deposit{
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
},
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
ConfirmationHeight: 123,
},
}
_, err := withdrawAllDepositOutpoints(deposits)
if err == nil {
t.Fatal("expected unconfirmed deposit to fail all withdrawal")
}
})
t.Run("returns all confirmed", func(t *testing.T) {
first := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
}
second := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
}
deposits := []*deposit.Deposit{
{
OutPoint: first,
ConfirmationHeight: 123,
},
{
OutPoint: second,
ConfirmationHeight: 124,
},
}
outpoints, err := withdrawAllDepositOutpoints(deposits)
if err != nil {
t.Fatalf("expected confirmed deposits to succeed: %v", err)
}
if len(outpoints) != 2 {
t.Fatalf("expected 2 outpoints, got %d", len(outpoints))
}
if outpoints[0] != first || outpoints[1] != second {
t.Fatal("expected all confirmed outpoints to remain selected")
}
})
}

View file

@ -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
@ -666,7 +665,6 @@ func (m *Manager) invalidateVanishedDeposits(ctx context.Context,
m.missingDeposits[outpoint]++
if m.missingDeposits[outpoint] < vanishedDepositThreshold {
log.Debugf("Waiting for another wallet observation before "+
"marking deposit %v replaced", outpoint)

View file

@ -276,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:

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"math"
"slices"
"sort"
"sync/atomic"
@ -843,11 +844,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) {
@ -868,14 +869,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
}
@ -907,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 {
// The deposit expiry height is the confirmation height plus the csv
// expiry.
depositExpiryHeight := confirmationHeight + csvExpiry
// The htlc expiry height is the current height plus the htlc
// cltv delta.
htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta
// Ensure that the deposit doesn't expire before the htlc.
if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta {
return false
if confirmationHeight == 0 {
return true
}
return true
// The deposit expiry height is the confirmation height plus the csv
// expiry.
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 {
if confirmationHeight == 0 {
return math.MaxUint32
}
depositExpiryHeight := confirmationHeight + csvExpiry
if depositExpiryHeight <= blockHeight {
return 0
}
return depositExpiryHeight - blockHeight
}
// DeduceSwapAmount calculates the swap amount based on the selected amount and

View file

@ -77,6 +77,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},
@ -220,6 +241,12 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
}
// 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.

View file

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

View file

@ -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
}
return m.openingDeposits, nil
}
if m.getErr != nil {
return nil, m.getErr
}
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) {

View file

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