cmd/loop: warn for auto-selected low-conf deposits

Remove the old "no confirmed deposits available" error now that mempool
deposits are listed immediately and can be selected for static loop-ins.

Reproduce the server static-address deposit selection order in the CLI using
the already-returned deposit metadata. This keeps the low-confirmation warning
focused on the deposits auto-selection would actually choose, so users only see
it when the swap payment may wait for the server confirmation-risk policy.
This commit is contained in:
Slyghtning 2026-04-27 11:23:22 +02:00
parent 30a14ecc82
commit cbe8eecb36
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 402 additions and 5 deletions

View file

@ -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"
lndcommands "github.com/lightningnetwork/lnd/cmd/commands"
"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 " +

220
cmd/loop/staticaddr_test.go Normal file
View file

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