From fae48d8d3816257f6498709e6a8b7cd648263b80 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 23 Jul 2026 16:26:32 -0300 Subject: [PATCH 1/2] cmd/loop: filter expiring static deposits --- cmd/loop/staticaddr.go | 165 ++++++++++-- cmd/loop/staticaddr_test.go | 249 ++++++++++++++++++ .../static-loop-in/14_loop-static-in.json | 118 +-------- docs/loop.1 | 3 + docs/loop.md | 33 +-- 5 files changed, 429 insertions(+), 139 deletions(-) diff --git a/cmd/loop/staticaddr.go b/cmd/loop/staticaddr.go index c973a0028..7076e8d66 100644 --- a/cmd/loop/staticaddr.go +++ b/cmd/loop/staticaddr.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "io" + "math" + "os" "sort" "strings" @@ -19,6 +22,15 @@ import ( "github.com/urfave/cli/v3" ) +const ( + // defaultStaticLoopInMinExpiryBlocks preserves the shared Loop-in safety + // floor so the CLI's default selection rule matches the underlying swap + // policy. + defaultStaticLoopInMinExpiryBlocks = uint64( + loopin.DefaultLoopInOnChainCltvDelta + loopin.DepositHtlcDelta, + ) +) + func init() { commands = append(commands, staticAddressCommands) } @@ -435,6 +447,15 @@ var staticAddressLoopInCommand = &cli.Command{ Name: "all", Usage: "loop in all static address deposits.", }, + &cli.Uint64Flag{ + Name: "min_expiry_blocks", + // Keep the default tied to the shared Loop-in safety policy; + // explicit --all overrides below this value are accepted with a + // warning. + Value: defaultStaticLoopInMinExpiryBlocks, + Usage: "minimum remaining blocks required for " + + "confirmed deposits selected by --all.", + }, &cli.DurationFlag{ Name: "payment_timeout", Usage: "the maximum time in seconds that the server " + @@ -519,6 +540,18 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { lastHop []byte paymentTimeoutSeconds = uint32(loopin.DefaultPaymentTimeoutSeconds) ) + minExpiryBlocks, err := staticAddressLoopInMinExpiryBlocks( + isAllSelected, cmd.IsSet("min_expiry_blocks"), + cmd.Uint64("min_expiry_blocks"), + ) + if err != nil { + return err + } + if cmd.IsSet("min_expiry_blocks") && + minExpiryBlocks < defaultStaticLoopInMinExpiryBlocks { + + writeStaticLoopInMinExpiryWarning(os.Stdout, minExpiryBlocks) + } // Validate our label early so that we can fail before getting a quote. if err := labels.Validate(label); err != nil { @@ -565,7 +598,18 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { return errors.New("cannot select all and specific utxos") case isAllSelected: - depositOutpoints = depositsToOutpoints(allDeposits) + var skipped []skippedStaticLoopInDeposit + depositOutpoints, skipped, err = selectAllStaticLoopInDeposits( + allDeposits, minExpiryBlocks, + ) + if len(skipped) > 0 { + writeSkippedStaticLoopInDeposits( + os.Stdout, skipped, minExpiryBlocks, + ) + } + if err != nil { + return err + } case isUtxoSelected: depositOutpoints = cmd.StringSlice("utxo") @@ -669,6 +713,106 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error { return nil } +// staticAddressLoopInMinExpiryBlocks validates expiry floors only for --all +// selection and keeps them representable for int64-backed deposit comparisons; +// below-default values are allowed and warned about separately. +func staticAddressLoopInMinExpiryBlocks(isAllSelected bool, isMinExpirySet bool, + minExpiryBlocks uint64) (uint64, error) { + + if isMinExpirySet && !isAllSelected { + return 0, errors.New("--min_expiry_blocks requires --all") + } + + if minExpiryBlocks > uint64(math.MaxInt64) { + return 0, fmt.Errorf("--min_expiry_blocks cannot exceed %d", + math.MaxInt64) + } + + return minExpiryBlocks, nil +} + +// writeStaticLoopInMinExpiryWarning centralizes the below-default warning so the +// CLI consistently explains the safety tradeoff for explicit lower thresholds. +func writeStaticLoopInMinExpiryWarning(w io.Writer, minExpiryBlocks uint64) { + fmt.Fprintf( + w, "\nWARNING: --min_expiry_blocks is set to %d, below the "+ + "default safety minimum of %d. This may select confirmed "+ + "deposits closer to expiry than the shared Loop-in safety "+ + "policy, increasing the risk that selected deposits expire "+ + "before the swap completes.\n", + minExpiryBlocks, defaultStaticLoopInMinExpiryBlocks, + ) +} + +// skippedStaticLoopInDeposit records deposits that --all left behind so the +// CLI can explain them back to the user in a deterministic, reviewable order. +type skippedStaticLoopInDeposit struct { + outpoint string + remainingBlocks int64 +} + +// selectAllStaticLoopInDeposits filters confirmed deposits against the expiry +// floor so automatic --all selection only quotes safe outputs while still +// reporting skipped entries for review. +func selectAllStaticLoopInDeposits(deposits []*looprpc.Deposit, + minExpiryBlocks uint64) ([]string, []skippedStaticLoopInDeposit, error) { + + depositOutpoints := make([]string, 0, len(deposits)) + skipped := make([]skippedStaticLoopInDeposit, 0) + for _, deposit := range deposits { + if !isDepositEligibleForStaticLoopInAll(deposit, minExpiryBlocks) { + skipped = append(skipped, skippedStaticLoopInDeposit{ + outpoint: deposit.Outpoint, + remainingBlocks: deposit.BlocksUntilExpiry, + }) + continue + } + + depositOutpoints = append(depositOutpoints, deposit.Outpoint) + } + + if len(depositOutpoints) == 0 { + return nil, skipped, fmt.Errorf("no deposited outputs meet the "+ + "minimum expiry of %d blocks", minExpiryBlocks) + } + + return depositOutpoints, skipped, nil +} + +// isDepositEligibleForStaticLoopInAll centralizes the Loop-in expiry check so +// automatic selection and warning selection stay in sync while unconfirmed +// deposits remain eligible. +func isDepositEligibleForStaticLoopInAll(deposit *looprpc.Deposit, + minExpiryBlocks uint64) bool { + + if deposit.ConfirmationHeight <= 0 { + // Unconfirmed deposits have not started their relative CSV timeout, + // so they are not yet close to expiry. + return true + } + + return deposit.BlocksUntilExpiry >= int64(minExpiryBlocks) +} + +// writeSkippedStaticLoopInDeposits emits skipped deposits in a deterministic +// order so users can compare the CLI report with the selection rules. +func writeSkippedStaticLoopInDeposits(w io.Writer, + skipped []skippedStaticLoopInDeposit, minExpiryBlocks uint64) { + + sortedSkipped := append([]skippedStaticLoopInDeposit(nil), skipped...) + sort.Slice(sortedSkipped, func(i, j int) bool { + return sortedSkipped[i].outpoint < sortedSkipped[j].outpoint + }) + + fmt.Fprintln(w, "Skipping static address deposits too close to expiry:") + for _, deposit := range sortedSkipped { + fmt.Fprintf( + w, "- %s: %d blocks remaining, requires at least %d\n", + deposit.outpoint, deposit.remainingBlocks, minExpiryBlocks, + ) + } +} + func containsDuplicates(outpoints []string) bool { found := make(map[string]struct{}) for _, outpoint := range outpoints { @@ -681,15 +825,6 @@ func containsDuplicates(outpoints []string) bool { return false } -func depositsToOutpoints(deposits []*looprpc.Deposit) []string { - outpoints := make([]string, 0, len(deposits)) - for _, deposit := range deposits { - outpoints = append(outpoints, deposit.Outpoint) - } - - return outpoints -} - var warningSelectionDustLimit = int64(lnwallet.DustLimitForSize(input.P2TRSize)) // warningDepositOutpoints returns the deposit outpoints to check for @@ -754,14 +889,10 @@ 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 { + if !isDepositEligibleForStaticLoopInAll( + deposit, defaultStaticLoopInMinExpiryBlocks, + ) { continue } diff --git a/cmd/loop/staticaddr_test.go b/cmd/loop/staticaddr_test.go index 2cc88ad66..41f7660bb 100644 --- a/cmd/loop/staticaddr_test.go +++ b/cmd/loop/staticaddr_test.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "fmt" + "math" "strings" "testing" @@ -13,6 +16,252 @@ import ( "github.com/stretchr/testify/require" ) +// TestSelectAllStaticLoopInDeposits_defaultMinExpiry verifies that the shared +// default floor keeps the canonical expiry threshold and skips deposits that +// are already too close to expiry. +func TestSelectAllStaticLoopInDeposits_defaultMinExpiry(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "at-threshold", + ConfirmationHeight: 1, + BlocksUntilExpiry: int64(defaultStaticLoopInMinExpiryBlocks), + }, + { + Outpoint: "below-threshold", + ConfirmationHeight: 1, + BlocksUntilExpiry: int64(defaultStaticLoopInMinExpiryBlocks) - 1, + }, + } + + selected, skipped, err := selectAllStaticLoopInDeposits( + deposits, defaultStaticLoopInMinExpiryBlocks, + ) + + require.NoError(t, err) + require.Equal(t, []string{"at-threshold"}, selected) + require.Equal(t, []skippedStaticLoopInDeposit{ + { + outpoint: "below-threshold", + remainingBlocks: int64(defaultStaticLoopInMinExpiryBlocks) - 1, + }, + }, skipped) + + // Lowering the floor below the default must pull the near-expiry + // deposit back into the selection instead of skipping it. + loweredSelected, loweredSkipped, err := selectAllStaticLoopInDeposits( + deposits, defaultStaticLoopInMinExpiryBlocks-1, + ) + + require.NoError(t, err) + require.Equal( + t, []string{"at-threshold", "below-threshold"}, loweredSelected, + ) + require.Empty(t, loweredSkipped) +} + +// TestSelectAllStaticLoopInDeposits_usesRaisedMinExpiry verifies that a higher +// user-supplied floor narrows --all selection without changing the canonical +// default. +func TestSelectAllStaticLoopInDeposits_usesRaisedMinExpiry(t *testing.T) { + t.Parallel() + + minExpiry := defaultStaticLoopInMinExpiryBlocks + 25 + deposits := []*looprpc.Deposit{ + { + Outpoint: "canonical-eligible", + ConfirmationHeight: 1, + BlocksUntilExpiry: int64(defaultStaticLoopInMinExpiryBlocks), + }, + { + Outpoint: "raised-eligible", + ConfirmationHeight: 1, + BlocksUntilExpiry: int64(minExpiry), + }, + } + + selected, skipped, err := selectAllStaticLoopInDeposits( + deposits, minExpiry, + ) + + require.NoError(t, err) + require.Equal(t, []string{"raised-eligible"}, selected) + require.Equal(t, []skippedStaticLoopInDeposit{ + { + outpoint: "canonical-eligible", + remainingBlocks: int64(defaultStaticLoopInMinExpiryBlocks), + }, + }, skipped) +} + +// TestSelectAllStaticLoopInDeposits_keepsUnconfirmed verifies that mempool +// deposits stay eligible when --all applies the expiry filter. +func TestSelectAllStaticLoopInDeposits_keepsUnconfirmed(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool", + ConfirmationHeight: 0, + BlocksUntilExpiry: 1, + }, + } + + // Use a floor far above the deposit's remaining blocks so the only + // reason it can survive selection is the unconfirmed short-circuit. + selected, skipped, err := selectAllStaticLoopInDeposits( + deposits, defaultStaticLoopInMinExpiryBlocks+10_000, + ) + + require.NoError(t, err) + require.Equal(t, []string{"mempool"}, selected) + require.Empty(t, skipped) +} + +// TestSelectAllStaticLoopInDeposits_keepsNegativeConfirmationHeight verifies +// that sentinel negative confirmation heights still behave like unconfirmed +// deposits for selection. +func TestSelectAllStaticLoopInDeposits_keepsNegativeConfirmationHeight(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "mempool-sentinel", + ConfirmationHeight: -1, + BlocksUntilExpiry: 1, + }, + } + + // Use a floor far above the deposit's remaining blocks so the only + // reason it can survive selection is the unconfirmed short-circuit. + selected, skipped, err := selectAllStaticLoopInDeposits( + deposits, defaultStaticLoopInMinExpiryBlocks+10_000, + ) + + require.NoError(t, err) + require.Equal(t, []string{"mempool-sentinel"}, selected) + require.Empty(t, skipped) +} + +// TestSelectAllStaticLoopInDeposits_returnsErrorWhenAllSkipped verifies that +// --all reports a hard error when no deposit meets the expiry floor. +func TestSelectAllStaticLoopInDeposits_returnsErrorWhenAllSkipped(t *testing.T) { + t.Parallel() + + deposits := []*looprpc.Deposit{ + { + Outpoint: "too-close", + ConfirmationHeight: 1, + BlocksUntilExpiry: int64(defaultStaticLoopInMinExpiryBlocks) - 1, + }, + } + + selected, skipped, err := selectAllStaticLoopInDeposits( + deposits, defaultStaticLoopInMinExpiryBlocks, + ) + + require.ErrorContains(t, err, "no deposited outputs meet") + require.Empty(t, selected) + require.Len(t, skipped, 1) +} + +// TestWriteSkippedStaticLoopInDeposits_reportsSkippedDeposits verifies that the +// skipped-deposit report stays deterministic and user-readable for review. +func TestWriteSkippedStaticLoopInDeposits_reportsSkippedDeposits(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + skipped := []skippedStaticLoopInDeposit{ + { + outpoint: "tx-b:1", + remainingBlocks: int64(defaultStaticLoopInMinExpiryBlocks) - 1, + }, + { + outpoint: "tx-a:0", + remainingBlocks: int64(defaultStaticLoopInMinExpiryBlocks - 50), + }, + } + + writeSkippedStaticLoopInDeposits( + &output, skipped, defaultStaticLoopInMinExpiryBlocks, + ) + + require.Equal( + t, + "Skipping static address deposits too close to expiry:\n"+ + fmt.Sprintf( + "- tx-a:0: %d blocks remaining, requires at least %d\n", + defaultStaticLoopInMinExpiryBlocks-50, + defaultStaticLoopInMinExpiryBlocks, + )+ + fmt.Sprintf( + "- tx-b:1: %d blocks remaining, requires at least %d\n", + defaultStaticLoopInMinExpiryBlocks-1, + defaultStaticLoopInMinExpiryBlocks, + ), + output.String(), + ) +} + +// TestStaticAddressLoopInMinExpiryBlocksAcceptsBelowDefaultWithAll verifies that +// informed --all users can choose an expiry floor below the default. +func TestStaticAddressLoopInMinExpiryBlocksAcceptsBelowDefaultWithAll(t *testing.T) { + t.Parallel() + + minExpiry := defaultStaticLoopInMinExpiryBlocks - 1 + + actualMinExpiry, err := staticAddressLoopInMinExpiryBlocks( + true, true, minExpiry, + ) + + require.NoError(t, err) + require.Equal(t, minExpiry, actualMinExpiry) +} + +// TestWriteStaticLoopInMinExpiryWarningReportsSafetyTradeoff verifies that the +// below-default warning includes the configured value, default, and risk marker. +func TestWriteStaticLoopInMinExpiryWarningReportsSafetyTradeoff(t *testing.T) { + t.Parallel() + + var output bytes.Buffer + minExpiry := defaultStaticLoopInMinExpiryBlocks - 1 + + writeStaticLoopInMinExpiryWarning(&output, minExpiry) + + warning := output.String() + require.Contains(t, warning, fmt.Sprintf("set to %d", minExpiry)) + require.Contains(t, warning, fmt.Sprintf( + "safety minimum of %d", defaultStaticLoopInMinExpiryBlocks, + )) + require.Contains(t, warning, "WARNING") + require.Contains(t, warning, "risk") +} + +// TestStaticAddressLoopInMinExpiryBlocksRejectsTooLargeValue verifies that the +// uint64-to-int64 boundary check is preserved before deposit comparisons. +func TestStaticAddressLoopInMinExpiryBlocksRejectsTooLargeValue(t *testing.T) { + t.Parallel() + + minExpiry := uint64(math.MaxInt64) + 1 + + _, err := staticAddressLoopInMinExpiryBlocks(true, true, minExpiry) + + require.ErrorContains(t, err, "cannot exceed") +} + +// TestStaticAddressLoopInMinExpiryBlocksRejectsFlagWithoutAll verifies that the +// custom expiry floor remains tied to --all selection. +func TestStaticAddressLoopInMinExpiryBlocksRejectsFlagWithoutAll(t *testing.T) { + t.Parallel() + + _, err := staticAddressLoopInMinExpiryBlocks( + false, true, defaultStaticLoopInMinExpiryBlocks, + ) + + require.ErrorContains(t, err, "requires --all") +} + // TestLowConfDepositWarningConfirmedOnly verifies confirmed deposits below the // conservative warning threshold are included in the warning text. func TestLowConfDepositWarningConfirmedOnly(t *testing.T) { diff --git a/cmd/loop/testdata/sessions/static-loop-in/14_loop-static-in.json b/cmd/loop/testdata/sessions/static-loop-in/14_loop-static-in.json index f79e0547e..8a6bb51a6 100644 --- a/cmd/loop/testdata/sessions/static-loop-in/14_loop-static-in.json +++ b/cmd/loop/testdata/sessions/static-loop-in/14_loop-static-in.json @@ -19,136 +19,42 @@ "kind": "stdout", "data": { "lines": [ - "NAME:\n" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - " loop static in - Loop in funds from static address deposits." - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - "\n", + "NAME:\n", + " loop static in - Loop in funds from static address deposits.\n", "\n", - "USAGE:\n" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ + "USAGE:\n", " loop static in [options] [amt] [--all | --utxo xxx:xx]\n", "\n", "DESCRIPTION:\n", - " " - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - "\n", + " \n", " Requests a loop-in swap based on static address deposits. After the\n", " creation of a static address funds can be sent to it. Once the funds are\n", - " " - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - " confirmed on-chain they can be swapped instantaneously. If deposited\n", + " confirmed on-chain they can be swapped instantaneously. If deposited\n", " funds are not needed they can we withdrawn back to the local lnd wallet.\n", " \n", "\n", - "OPTIONS:\n" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ + "OPTIONS:\n", " --utxo string [ --utxo string ] specify the utxos of deposits as outpoints(tx:idx) that should be looped in.\n", " --all loop in all static address deposits. (default: false)\n", - " --payment_timeout duration " - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - " the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out. (default: 0s)\n", + " --min_expiry_blocks uint minimum remaining blocks required for confirmed deposits selected by --all. (default: 1050)\n", + " --payment_timeout duration the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out. (default: 0s)\n", " --amt uint, --amount uint the number of satoshis that should be swapped from the selected deposits. If thereis change it is sent back to the static address. (default: 0)\n", " --fast Usage: complete the swap faster by paying a higher fee, so the change output is available sooner (default: false)\n", " --max_swap_fee_sat uint the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected. (default: 0)\n", " --max_swap_fee_ppm uint the maximum swap fee expressed in parts per million of the swap amount. If set together with --max_swap_fee_sat the tighter cap is used. (default: 0)\n", " --last_hop string the pubkey of the last hop to use for this swap\n", " --label string an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved].\n", - " --route_hints string [ --route_hints string ]" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - " route hints that can each be individually used to assist in reaching the invoice's destination\n", + " --route_hints string [ --route_hints string ] route hints that can each be individually used to assist in reaching the invoice's destination\n", " --private generates and passes routehints. Should be used if the connected node is only reachable via private channels (default: false)\n", " --force Assumes yes during confirmation. Using this option will result in an immediate swap (default: false)\n", " --verbose, -v show expanded details (default: false)\n", " --help, -h show help\n", - "\n" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ + "\n", "GLOBAL OPTIONS:\n", - " --rpcserver string " - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - "loopd daemon address host:port (default: \"localhost:11010\") [$LOOPCLI_RPCSERVER]\n", + " --rpcserver string loopd daemon address host:port (default: \"localhost:11010\") [$LOOPCLI_RPCSERVER]\n", " --network string, -n string the network loop is running on e.g. mainnet, testnet, etc. (default: \"mainnet\") [$LOOPCLI_NETWORK]\n", " --loopdir string path to loop's base directory (default: ~/.loop) [$LOOPCLI_LOOPDIR]\n", - " --tlscertpath string" - ] - } - }, - { - "time_ms": 1, - "kind": "stdout", - "data": { - "lines": [ - " path to loop's TLS certificate (default: ~/.loop/mainnet/tls.cert) [$LOOPCLI_TLSCERTPATH]\n", + " --tlscertpath string path to loop's TLS certificate (default: ~/.loop/mainnet/tls.cert) [$LOOPCLI_TLSCERTPATH]\n", " --macaroonpath string path to macaroon file (default: ~/.loop/mainnet/loop.macaroon) [$LOOPCLI_MACAROONPATH]\n" ] } diff --git a/docs/loop.1 b/docs/loop.1 index 1e21905b8..cba949df4 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -532,6 +532,9 @@ Loop in funds from static address deposits. .PP \fB--max_swap_fee_sat\fP="": the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected. (default: 0) +.PP +\fB--min_expiry_blocks\fP="": minimum remaining blocks required for confirmed deposits selected by --all. (default: 1050) + .PP \fB--payment_timeout\fP="": the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out. (default: 0s) diff --git a/docs/loop.md b/docs/loop.md index 316cebdce..b26ac80f3 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -670,22 +670,23 @@ $ loop [GLOBAL FLAGS] static in [COMMAND FLAGS] [amt] [--all | --utxo xxx:xx] The following flags are supported: -| Name | Description | Type | Default value | -|--------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|:-------------:| -| `--utxo="…"` | specify the utxos of deposits as outpoints(tx:idx) that should be looped in | string | `[]` | -| `--all` | loop in all static address deposits | bool | `false` | -| `--payment_timeout="…"` | the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out | duration | `0s` | -| `--amt="…"` (`--amount`) | the number of satoshis that should be swapped from the selected deposits. If thereis change it is sent back to the static address | uint | `0` | -| `--fast` | Usage: complete the swap faster by paying a higher fee, so the change output is available sooner | bool | `false` | -| `--max_swap_fee_sat="…"` | the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected | uint | `0` | -| `--max_swap_fee_ppm="…"` | the maximum swap fee expressed in parts per million of the swap amount. If set together with --max_swap_fee_sat the tighter cap is used | uint | `0` | -| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | -| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | -| `--route_hints="…"` | route hints that can each be individually used to assist in reaching the invoice's destination | string | `[]` | -| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | -| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | -| `--verbose` (`-v`) | show expanded details | bool | `false` | -| `--help` (`-h`) | show help | bool | `false` | +| Name | Description | Type | Default value | +|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|:-------------:| +| `--utxo="…"` | specify the utxos of deposits as outpoints(tx:idx) that should be looped in | string | `[]` | +| `--all` | loop in all static address deposits | bool | `false` | +| `--min_expiry_blocks="…"` | minimum remaining blocks required for confirmed deposits selected by --all | uint | `1050` | +| `--payment_timeout="…"` | the maximum time in seconds that the server is allowed to take for the swap payment. The client can retry the swap with adjusted parameters after the payment timed out | duration | `0s` | +| `--amt="…"` (`--amount`) | the number of satoshis that should be swapped from the selected deposits. If thereis change it is sent back to the static address | uint | `0` | +| `--fast` | Usage: complete the swap faster by paying a higher fee, so the change output is available sooner | bool | `false` | +| `--max_swap_fee_sat="…"` | the maximum swap fee in satoshis. If set, the swap is rejected when the quoted fee exceeds this cap. The maximum allowed value is 10000000. On-chain fees for creating static deposits are unaffected | uint | `0` | +| `--max_swap_fee_ppm="…"` | the maximum swap fee expressed in parts per million of the swap amount. If set together with --max_swap_fee_sat the tighter cap is used | uint | `0` | +| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | +| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | +| `--route_hints="…"` | route hints that can each be individually used to assist in reaching the invoice's destination | string | `[]` | +| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | +| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | +| `--verbose` (`-v`) | show expanded details | bool | `false` | +| `--help` (`-h`) | show help | bool | `false` | ### `static openchannel` subcommand From d7c016f9863b5025eee45c6ba7bcdcfbf2db4934 Mon Sep 17 00:00:00 2001 From: Gustavo Stingelin Date: Thu, 23 Jul 2026 16:27:00 -0300 Subject: [PATCH 2/2] cmd/loop: record expiry-filter replay --- ..._loop-static-in-all-min-expiry-cancel.json | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 cmd/loop/testdata/sessions/static-loop-in/27_loop-static-in-all-min-expiry-cancel.json diff --git a/cmd/loop/testdata/sessions/static-loop-in/27_loop-static-in-all-min-expiry-cancel.json b/cmd/loop/testdata/sessions/static-loop-in/27_loop-static-in-all-min-expiry-cancel.json new file mode 100644 index 000000000..b9dd3ddaa --- /dev/null +++ b/cmd/loop/testdata/sessions/static-loop-in/27_loop-static-in-all-min-expiry-cancel.json @@ -0,0 +1,195 @@ +{ + "metadata": { + "args": [ + "loop", + "static", + "in", + "--all", + "--min_expiry_blocks", + "14398", + "--network", + "regtest" + ], + "env": {}, + "version": "0.33.3-beta commit=v0.33.3-beta-78-g41bc5f85dfb6cbb1baf6905ad1a73423043ba6a8-dirty commit_hash=41bc5f85dfb6cbb1baf6905ad1a73423043ba6a8", + "run_error": "swap canceled", + "duration": 872162875, + "clock_start_unix": 1784814954 + }, + "events": [ + { + "time_ms": 0, + "kind": "stdin", + "data": { + "text": "n\n" + } + }, + { + "time_ms": 1, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/ListStaticAddressDeposits", + "event": "request", + "message_type": "looprpc.ListStaticAddressDepositsRequest", + "payload": { + "state_filter": "DEPOSITED", + "outpoints": [] + } + } + }, + { + "time_ms": 21, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/ListStaticAddressDeposits", + "event": "response", + "message_type": "looprpc.ListStaticAddressDepositsResponse", + "payload": { + "filtered_deposits": [ + { + "id": "V/nBBK1ZOttFd6qaiHUli/ybqYE7Ox/0ml3bU06PsTQ=", + "state": "DEPOSITED", + "outpoint": "d54394c5d5638f2e72ce2f7afa8e0c604c4989da36c822bfc0026fd69bab8e62:0", + "value": "300000", + "confirmation_height": "122", + "blocks_until_expiry": "14395", + "swap_hash": "" + }, + { + "id": "99EbTf87OKBI6G7Swb/w3/PFYnRMOKwsl1Hm2Oj9pj4=", + "state": "DEPOSITED", + "outpoint": "e55fd0ffcb73c8088141142c2ee1266e5713ce7f1384233c4d8091e6d6a7cefb:0", + "value": "350000", + "confirmation_height": "127", + "blocks_until_expiry": "14400", + "swap_hash": "" + } + ] + } + } + }, + { + "time_ms": 21, + "kind": "stdout", + "data": { + "lines": [ + "Skipping static address deposits too close to expiry:\n", + "- d54394c5d5638f2e72ce2f7afa8e0c604c4989da36c822bfc0026fd69bab8e62:0: 14395 blocks remaining, requires at least 14398\n" + ] + } + }, + { + "time_ms": 21, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetLoopInQuote", + "event": "request", + "message_type": "looprpc.QuoteRequest", + "payload": { + "amt": "0", + "conf_target": 0, + "external_htlc": false, + "swap_publication_deadline": "0", + "loop_in_last_hop": "", + "loop_in_route_hints": [], + "private": false, + "deposit_outpoints": [ + "e55fd0ffcb73c8088141142c2ee1266e5713ce7f1384233c4d8091e6d6a7cefb:0" + ], + "asset_info": null, + "auto_select_deposits": false, + "fast": false + } + } + }, + { + "time_ms": 869, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetLoopInQuote", + "event": "response", + "message_type": "looprpc.InQuoteResponse", + "payload": { + "swap_fee_sat": "1820", + "htlc_publish_fee_sat": "0", + "cltv_delta": 0, + "conf_target": 0, + "quoted_amt": "350000" + } + } + }, + { + "time_ms": 869, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "request", + "message_type": "looprpc.StaticAddressSummaryRequest", + "payload": {} + } + }, + { + "time_ms": 871, + "kind": "grpc", + "data": { + "method": "/looprpc.SwapClient/GetStaticAddressSummary", + "event": "response", + "message_type": "looprpc.StaticAddressSummaryResponse", + "payload": { + "static_address": "bcrt1pnehdv95rkjnfzhgevhca07fqezjsh4uctf58yv9c4sk6j80qzjxs423q2g", + "relative_expiry_blocks": "14400", + "total_num_deposits": 2, + "value_unconfirmed_satoshis": "0", + "value_deposited_satoshis": "650000", + "value_expired_satoshis": "0", + "value_withdrawn_satoshis": "0", + "value_looped_in_satoshis": "0", + "value_htlc_timeout_sweeps_satoshis": "0", + "value_channels_opened": "0" + } + } + }, + { + "time_ms": 871, + "kind": "stdout", + "data": { + "lines": [ + "\n", + "WARNING: The following deposits are below the conservative 6-confirmation threshold:\n", + " - e55fd0ffcb73c8088141142c2ee1266e5713ce7f1384233c4d8091e6d6a7cefb:0 (1 confirmations)\n", + "The swap payment for these deposits may wait for more confirmations depending on the server's confirmation-risk policy.\n", + "\n" + ] + } + }, + { + "time_ms": 871, + "kind": "stdout", + "data": { + "lines": [ + "Previously deposited on-chain: 350000 sat\n", + "Receive off-chain: 348180 sat\n", + "Estimated total fee: 1820 sat\n", + "\n", + "CONTINUE SWAP? (y/n): " + ] + } + }, + { + "time_ms": 872, + "kind": "stderr", + "data": { + "lines": [ + "[loop] swap canceled\n" + ] + } + }, + { + "time_ms": 872, + "kind": "exit", + "data": { + "run_error": "swap canceled" + } + } + ] +}