From 0223caa3707e9ad7e891dc3480dcdd4db4361e25 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 13:53:29 +0200 Subject: [PATCH] staticaddr/loopin: treat unconfirmed deposits as swappable Static address deposits with no confirmation height have not started their CSV timeout yet, so keep them eligible for loop-in selection instead of treating them as already near expiry. Prefer confirmed deposits before unconfirmed ones during automatic selection, and share the remaining-lifetime calculation used by the selector. --- staticaddr/loopin/manager.go | 71 +++++++++++++++++++++---------- staticaddr/loopin/manager_test.go | 27 ++++++++++++ 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 51969578..0b4ee28d 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "math" "slices" "sort" "sync/atomic" @@ -845,11 +846,11 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) ( ) } -// SelectDeposits sorts the deposits by amount in descending order, then by -// blocks-until-expiry in ascending order. It then selects the deposits that -// are needed to cover the amount requested without leaving a dust change. It -// returns an error if the sum of deposits minus dust is less than the requested -// amount. +// SelectDeposits sorts deposits by confirmation status first, then by amount in +// descending order, then by blocks-until-expiry in ascending order. It then +// selects the deposits that are needed to cover the amount requested without +// leaving a dust change. It returns an error if the sum of deposits minus dust +// is less than the requested amount. func SelectDeposits(targetAmount btcutil.Amount, unfilteredDeposits []*deposit.Deposit, csvExpiry uint32, blockHeight uint32) ([]*deposit.Deposit, error) { @@ -871,14 +872,25 @@ func SelectDeposits(targetAmount btcutil.Amount, deposits = append(deposits, d) } - // Sort the deposits by amount in descending order, then by - // blocks-until-expiry in ascending order. + // Sort confirmed deposits ahead of unconfirmed ones so auto-selection + // prefers deposits the server can accept immediately. Within each group + // we prefer larger deposits, then earlier expiries. sort.Slice(deposits, func(i, j int) bool { + iConfirmationHeight := uint32(deposits[i].GetConfirmationHeight()) + jConfirmationHeight := uint32(deposits[j].GetConfirmationHeight()) + iConfirmed := iConfirmationHeight > 0 + jConfirmed := jConfirmationHeight > 0 + if iConfirmed != jConfirmed { + return iConfirmed + } + if deposits[i].Value == deposits[j].Value { - iExp := uint32(deposits[i].GetConfirmationHeight()) + - csvExpiry - blockHeight - jExp := uint32(deposits[j].GetConfirmationHeight()) + - csvExpiry - blockHeight + iExp := blocksUntilDepositExpiry( + iConfirmationHeight, blockHeight, csvExpiry, + ) + jExp := blocksUntilDepositExpiry( + jConfirmationHeight, blockHeight, csvExpiry, + ) return iExp < jExp } @@ -910,20 +922,33 @@ func SelectDeposits(targetAmount btcutil.Amount, // IsSwappable checks if a deposit is swappable. It returns true if the deposit // is not expired and the htlc is not too close to expiry. func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool { - // 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 diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index ed31df8a..759acea0 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -81,6 +81,27 @@ func TestSelectDeposits(t *testing.T) { expected: []*deposit.Deposit{d3}, expectedErr: "", }, + { + name: "prefer confirmed deposit over larger unconfirmed one", + deposits: []*deposit.Deposit{ + { + Value: 2_000_000, + ConfirmationHeight: 0, + }, + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + targetValue: 1_000_000, + expected: []*deposit.Deposit{ + { + Value: 1_500_000, + ConfirmationHeight: 5_004, + }, + }, + expectedErr: "", + }, { name: "single deposit insufficient by 1", deposits: []*deposit.Deposit{d1}, @@ -357,6 +378,12 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } +// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered +// swappable because its CSV timeout has not started yet. +func TestIsSwappableUnconfirmed(t *testing.T) { + require.True(t, IsSwappable(0, 5000, 1000)) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { // activeDeposits is the set returned by GetActiveDepositsInState.