staticaddr/loopin: treat unconfirmed deposits as swappable

Static address deposits with no confirmation height have not started
their CSV timeout yet, so keep them eligible for loop-in selection
instead of treating them as already near expiry. Prefer confirmed
deposits before unconfirmed ones during automatic selection, and share
the remaining-lifetime calculation used by the selector.
This commit is contained in:
Slyghtning 2026-07-08 13:53:29 +02:00
parent 557ba99513
commit 0223caa370
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 75 additions and 23 deletions

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"math"
"slices"
"sort"
"sync/atomic"
@ -845,11 +846,11 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) (
)
}
// SelectDeposits sorts the deposits by amount in descending order, then by
// blocks-until-expiry in ascending order. It then selects the deposits that
// are needed to cover the amount requested without leaving a dust change. It
// returns an error if the sum of deposits minus dust is less than the requested
// amount.
// SelectDeposits sorts deposits by confirmation status first, then by amount in
// descending order, then by blocks-until-expiry in ascending order. It then
// selects the deposits that are needed to cover the amount requested without
// leaving a dust change. It returns an error if the sum of deposits minus dust
// is less than the requested amount.
func SelectDeposits(targetAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry uint32,
blockHeight uint32) ([]*deposit.Deposit, error) {
@ -871,14 +872,25 @@ func SelectDeposits(targetAmount btcutil.Amount,
deposits = append(deposits, d)
}
// Sort the deposits by amount in descending order, then by
// blocks-until-expiry in ascending order.
// Sort confirmed deposits ahead of unconfirmed ones so auto-selection
// prefers deposits the server can accept immediately. Within each group
// we prefer larger deposits, then earlier expiries.
sort.Slice(deposits, func(i, j int) bool {
iConfirmationHeight := uint32(deposits[i].GetConfirmationHeight())
jConfirmationHeight := uint32(deposits[j].GetConfirmationHeight())
iConfirmed := iConfirmationHeight > 0
jConfirmed := jConfirmationHeight > 0
if iConfirmed != jConfirmed {
return iConfirmed
}
if deposits[i].Value == deposits[j].Value {
iExp := uint32(deposits[i].GetConfirmationHeight()) +
csvExpiry - blockHeight
jExp := uint32(deposits[j].GetConfirmationHeight()) +
csvExpiry - blockHeight
iExp := blocksUntilDepositExpiry(
iConfirmationHeight, blockHeight, csvExpiry,
)
jExp := blocksUntilDepositExpiry(
jConfirmationHeight, blockHeight, csvExpiry,
)
return iExp < jExp
}
@ -910,20 +922,33 @@ func SelectDeposits(targetAmount btcutil.Amount,
// IsSwappable checks if a deposit is swappable. It returns true if the deposit
// is not expired and the htlc is not too close to expiry.
func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool {
// 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

@ -81,6 +81,27 @@ func TestSelectDeposits(t *testing.T) {
expected: []*deposit.Deposit{d3},
expectedErr: "",
},
{
name: "prefer confirmed deposit over larger unconfirmed one",
deposits: []*deposit.Deposit{
{
Value: 2_000_000,
ConfirmationHeight: 0,
},
{
Value: 1_500_000,
ConfirmationHeight: 5_004,
},
},
targetValue: 1_000_000,
expected: []*deposit.Deposit{
{
Value: 1_500_000,
ConfirmationHeight: 5_004,
},
},
expectedErr: "",
},
{
name: "single deposit insufficient by 1",
deposits: []*deposit.Deposit{d1},
@ -357,6 +378,12 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) {
require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits)
}
// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered
// swappable because its CSV timeout has not started yet.
func TestIsSwappableUnconfirmed(t *testing.T) {
require.True(t, IsSwappable(0, 5000, 1000))
}
// mockDepositManager implements DepositManager for tests.
type mockDepositManager struct {
// activeDeposits is the set returned by GetActiveDepositsInState.