Merge pull request #1141 from lightninglabs/dyn-conf-tracker-staging
Some checks failed
CI / RPC compilation check (push) Has been cancelled
CI / SQL compilation check (push) Has been cancelled
CI / go mod check (push) Has been cancelled
CI / build and lint code (push) Has been cancelled
CI / verify that auto-generated documentation is up-to-date (push) Has been cancelled
CI / run unit-test sqlite3 race (push) Has been cancelled
CI / run unit-test postgres race (push) Has been cancelled
CI / run LiT itests (push) Has been cancelled
CI / run LiT unit tests (push) Has been cancelled

staticaddr: dyn-conf-tracker
This commit is contained in:
Slyghtning 2026-07-16 15:25:58 +02:00 committed by GitHub
commit 39c97e60d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 7563 additions and 384 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"
"github.com/lightningnetwork/lnd"
"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,7 @@ 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(errString)
return errors.New("no deposited outputs available")
}
var depositOutpoints []string
@ -614,6 +613,28 @@ 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.
summary, err := client.GetStaticAddressSummary(
ctx, &looprpc.StaticAddressSummaryRequest{},
)
if err != nil {
return err
}
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)
}

View file

@ -71,7 +71,7 @@ Base URL: `http://127.0.0.1:12345`
| `quote/` | `loop quote out` (success + verbose), `loop quote in` (help + verbose), `loop quote out` (help), `loop quote in` (deposit_outpoint success), `loop quote in` (positional + last_hop) |
| `static/` | `loop static withdraw` (no selection error), `loop static withdraw` (invalid utxo), `loop static withdraw` (all success), `loop static withdraw` (utxo + dest_addr success), `loop static listwithdrawals`, `loop static listswaps` |
| `static-autoloop/` | `loop setparams --loopinsource static-address` (success + no-experimental error), `loop getparams` (static-address loop-in source), `loop suggestswaps` (static loop-in suggestion) |
| `static-loop-in/` | `loop static new`, `loop static` (help), `loop static listunspent` (incl alias), `loop static listdeposits`, `loop static summary`, `loop static in` (multiple args/flags cases), `loop static in` (duplicate outpoints), `loop static in` (positional low amount error), `loop static in` (positional + last_hop + payment_timeout), `loop static in` (all cancel) |
| `static-loop-in/` | `loop static new`, `loop static` (help), `loop static listunspent` (incl alias), `loop static listdeposits`, `loop static summary`, `loop static in` (multiple args/flags cases), `loop static in` (duplicate outpoints), `loop static in` (positional low amount error), `loop static in` (positional + last_hop + payment_timeout), `loop static in` (all cancel), `loop static in` (explicit and automatically selected low-confirmation warnings) |
| `static-filters/` | `loop static listdeposits --filter ...` for each state (deposited/withdrawing/withdrawn/looping_in/looped_in/publish_expired_deposit/sweep_htlc_timeout/htlc_timeout_swept/wait_for_expiry_sweep/expired/failed) |
| `swaps/` | `loop listswaps` (success + conflicting filters + loop_out_only filters + loop_in_only), `loop swapinfo` (success + invalid id + id flag errors), `loop abandonswap` (help + invalid id + success) |

View file

@ -91,6 +91,37 @@
}
}
},
{
"time_ms": 65,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 65,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
"relative_expiry_blocks": "14400",
"total_num_deposits": 1,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "2500000",
"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": 65,
"kind": "stdout",

View file

@ -94,6 +94,37 @@
}
}
},
{
"time_ms": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
"relative_expiry_blocks": "14400",
"total_num_deposits": 1,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "500000",
"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": 500,
"kind": "grpc",

View file

@ -99,6 +99,37 @@
}
}
},
{
"time_ms": 446,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 446,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
"relative_expiry_blocks": "14400",
"total_num_deposits": 1,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "500000",
"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": 446,
"kind": "stdout",

View file

@ -127,6 +127,37 @@
}
}
},
{
"time_ms": 50,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 50,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1p604kzzh28764kkw45yps48weergwljggamhhe7tqfglzjzang6cs43f2m2",
"relative_expiry_blocks": "14400",
"total_num_deposits": 4,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "2546150",
"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": 50,
"kind": "grpc",

View file

@ -118,6 +118,37 @@
}
}
},
{
"time_ms": 45,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 45,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1p604kzzh28764kkw45yps48weergwljggamhhe7tqfglzjzang6cs43f2m2",
"relative_expiry_blocks": "14400",
"total_num_deposits": 3,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "2046150",
"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": 45,
"kind": "grpc",

View file

@ -0,0 +1,242 @@
{
"metadata": {
"args": [
"loop",
"static",
"in",
"--amt",
"500000",
"--utxo",
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
"--network",
"regtest"
],
"env": {},
"version": "0.31.7-beta commit=vbump-lndclient-70-g352a68cd43f1976a937faaf76041bc078fdd16f6 commit_hash=352a68cd43f1976a937faaf76041bc078fdd16f6",
"duration": 2826015164,
"clock_start_unix": 1769407086
},
"events": [
{
"time_ms": 3,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
"event": "request",
"message_type": "looprpc.ListStaticAddressDepositsRequest",
"payload": {
"state_filter": "DEPOSITED",
"outpoints": []
}
}
},
{
"time_ms": 22,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
"event": "response",
"message_type": "looprpc.ListStaticAddressDepositsResponse",
"payload": {
"filtered_deposits": [
{
"id": "6mq78FccC6ghF66fIIZhTqzqiykT3AVEtwwA3ng1PnE=",
"state": "DEPOSITED",
"outpoint": "188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
"value": "2500000",
"confirmation_height": "131",
"blocks_until_expiry": "14396",
"swap_hash": ""
}
]
}
}
},
{
"time_ms": 22,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLoopInQuote",
"event": "request",
"message_type": "looprpc.QuoteRequest",
"payload": {
"amt": "500000",
"conf_target": 0,
"external_htlc": false,
"swap_publication_deadline": "0",
"loop_in_last_hop": "",
"loop_in_route_hints": [],
"private": false,
"deposit_outpoints": [
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0"
],
"asset_info": null,
"auto_select_deposits": false,
"fast": false
}
}
},
{
"time_ms": 65,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLoopInQuote",
"event": "response",
"message_type": "looprpc.InQuoteResponse",
"payload": {
"swap_fee_sat": "1824",
"htlc_publish_fee_sat": "0",
"cltv_delta": 0,
"conf_target": 0,
"quoted_amt": "500000"
}
}
},
{
"time_ms": 65,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 65,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
"relative_expiry_blocks": "14400",
"total_num_deposits": 1,
"value_unconfirmed_satoshis": "0",
"value_deposited_satoshis": "2500000",
"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": 65,
"kind": "stdout",
"data": {
"lines": [
"\n",
"WARNING: The following deposits are below the conservative 6-confirmation threshold:\n",
" - 188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0 (5 confirmations)\n",
"The swap payment for these deposits may wait for more confirmations depending on the server's confirmation-risk policy.\n",
"\n",
"Previously deposited on-chain: 500000 sat\n",
"Receive off-chain: 498176 sat\n",
"Estimated total fee: 1824 sat\n",
"\n",
"CONTINUE SWAP? (y/n): {\n",
" \"amount\": \"2500000\",\n",
" \"change\": \"2000000\",\n",
" \"fast\": false,\n",
" \"htlc_cltv\": 1136,\n",
" \"initiation_height\": 136,\n",
" \"initiator\": \"loop-cli\",\n",
" \"label\": \"\",\n",
" \"max_swap_fee_satoshis\": \"1824\",\n",
" \"payment_timeout_seconds\": 60,\n",
" \"protocol_version\": \"V0\",\n",
" \"quoted_swap_fee_satoshis\": \"1824\",\n",
" \"state\": \"SignHtlcTx\",\n",
" \"swap_amount\": \"500000\",\n",
" \"swap_hash\": \"9f19fb5042a5de6da2f1ce183c5e224fd7802db408e9afdd598ee8174b2bce3f\",\n",
" \"used_deposits\": [\n",
" {\n",
" \"blocks_until_expiry\": \"14396\",\n",
" \"confirmation_height\": \"131\",\n",
" \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n",
" \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n",
" \"state\": \"LOOPING_IN\",\n",
" \"swap_hash\": \"\",\n",
" \"value\": \"2500000\"\n",
" }\n",
" ]\n",
"}\n"
]
}
},
{
"time_ms": 2358,
"kind": "stdin",
"data": {
"text": "y\n"
}
},
{
"time_ms": 2358,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
"event": "request",
"message_type": "looprpc.StaticAddressLoopInRequest",
"payload": {
"outpoints": [
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0"
],
"max_swap_fee_satoshis": "1824",
"last_hop": "",
"label": "",
"initiator": "loop-cli",
"route_hints": [],
"private": false,
"payment_timeout_seconds": 60,
"amount": "500000",
"fast": false
}
}
},
{
"time_ms": 2824,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
"event": "response",
"message_type": "looprpc.StaticAddressLoopInResponse",
"payload": {
"swap_hash": "nxn7UEKl3m2i8c4YPF4iT9eALbQI6a/dWY7oF0srzj8=",
"state": "SignHtlcTx",
"amount": "2500000",
"htlc_cltv": 1136,
"quoted_swap_fee_satoshis": "1824",
"max_swap_fee_satoshis": "1824",
"initiation_height": 136,
"protocol_version": "V0",
"label": "",
"initiator": "loop-cli",
"payment_timeout_seconds": 60,
"used_deposits": [
{
"id": "6mq78FccC6ghF66fIIZhTqzqiykT3AVEtwwA3ng1PnE=",
"state": "LOOPING_IN",
"outpoint": "188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
"value": "2500000",
"confirmation_height": "131",
"blocks_until_expiry": "14396",
"swap_hash": ""
}
],
"swap_amount": "500000",
"change": "2000000",
"fast": false
}
}
},
{
"time_ms": 2826,
"kind": "exit",
"data": {}
}
]
}

View file

@ -0,0 +1,231 @@
{
"metadata": {
"args": [
"/home/user/bin/loop",
"static",
"in",
"--network",
"regtest",
"500000",
"--payment_timeout",
"30s",
"--last_hop",
"0271d6e29301159d9e1cc5d3983479a51f3b3c0c682eda7f16aa1f47dfe09b22f7",
"--force"
],
"env": {
"HOME": "/home/user"
},
"version": "0.31.7-beta commit=v0.31.7-beta-28-g6d8ddfc59ddc2dcfd1a9b4e4b3c53a9cf15dd845 commit_hash=6d8ddfc59ddc2dcfd1a9b4e4b3c53a9cf15dd845",
"duration": 1078774451,
"clock_start_unix": 1769407086
},
"events": [
{
"time_ms": 4,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
"event": "request",
"message_type": "looprpc.ListStaticAddressDepositsRequest",
"payload": {
"state_filter": "DEPOSITED",
"outpoints": []
}
}
},
{
"time_ms": 179,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
"event": "response",
"message_type": "looprpc.ListStaticAddressDepositsResponse",
"payload": {
"filtered_deposits": [
{
"id": "j71tovlF3ikFqn+pOGB0TZOH00ZEhDYOluRnpR3jvJ0=",
"state": "DEPOSITED",
"outpoint": "9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0",
"value": "500000",
"confirmation_height": "0",
"blocks_until_expiry": "14400",
"swap_hash": ""
}
]
}
}
},
{
"time_ms": 180,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLoopInQuote",
"event": "request",
"message_type": "looprpc.QuoteRequest",
"payload": {
"amt": "500000",
"conf_target": 0,
"external_htlc": false,
"swap_publication_deadline": "0",
"loop_in_last_hop": "AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3",
"loop_in_route_hints": [],
"private": false,
"deposit_outpoints": [],
"asset_info": null,
"auto_select_deposits": true,
"fast": false
}
}
},
{
"time_ms": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLoopInQuote",
"event": "response",
"message_type": "looprpc.InQuoteResponse",
"payload": {
"swap_fee_sat": "1824",
"htlc_publish_fee_sat": "0",
"cltv_delta": 0,
"conf_target": 0,
"quoted_amt": "500000"
}
}
},
{
"time_ms": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "request",
"message_type": "looprpc.StaticAddressSummaryRequest",
"payload": {}
}
},
{
"time_ms": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
"event": "response",
"message_type": "looprpc.StaticAddressSummaryResponse",
"payload": {
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
"relative_expiry_blocks": "14400",
"total_num_deposits": 1,
"value_unconfirmed_satoshis": "500000",
"value_deposited_satoshis": "0",
"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": 500,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
"event": "request",
"message_type": "looprpc.StaticAddressLoopInRequest",
"payload": {
"outpoints": [],
"max_swap_fee_satoshis": "1824",
"last_hop": "AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3",
"label": "",
"initiator": "loop-cli",
"route_hints": [],
"private": false,
"payment_timeout_seconds": 30,
"amount": "500000",
"fast": false
}
}
},
{
"time_ms": 1078,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
"event": "response",
"message_type": "looprpc.StaticAddressLoopInResponse",
"payload": {
"swap_hash": "hDAjN0JANkGTlqt5ZN14uFsaSBqfHbc9tc3e5XwkQ+c=",
"state": "SignHtlcTx",
"amount": "500000",
"htlc_cltv": 1165,
"quoted_swap_fee_satoshis": "1824",
"max_swap_fee_satoshis": "1824",
"initiation_height": 165,
"protocol_version": "V0",
"label": "",
"initiator": "loop-cli",
"payment_timeout_seconds": 30,
"used_deposits": [
{
"id": "j71tovlF3ikFqn+pOGB0TZOH00ZEhDYOluRnpR3jvJ0=",
"state": "LOOPING_IN",
"outpoint": "9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0",
"value": "500000",
"confirmation_height": "0",
"blocks_until_expiry": "14400",
"swap_hash": ""
}
],
"swap_amount": "500000",
"change": "0",
"fast": false
}
}
},
{
"time_ms": 1078,
"kind": "stdout",
"data": {
"lines": [
"\n",
"WARNING: The following deposits are below the conservative 6-confirmation threshold:\n",
" - 9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0 (unconfirmed)\n",
"The swap payment for these deposits may wait for more confirmations depending on the server's confirmation-risk policy.\n",
"\n",
"{\n",
" \"amount\": \"500000\",\n",
" \"change\": \"0\",\n",
" \"fast\": false,\n",
" \"htlc_cltv\": 1165,\n",
" \"initiation_height\": 165,\n",
" \"initiator\": \"loop-cli\",\n",
" \"label\": \"\",\n",
" \"max_swap_fee_satoshis\": \"1824\",\n",
" \"payment_timeout_seconds\": 30,\n",
" \"protocol_version\": \"V0\",\n",
" \"quoted_swap_fee_satoshis\": \"1824\",\n",
" \"state\": \"SignHtlcTx\",\n",
" \"swap_amount\": \"500000\",\n",
" \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n",
" \"used_deposits\": [\n",
" {\n",
" \"blocks_until_expiry\": \"14400\",\n",
" \"confirmation_height\": \"0\",\n",
" \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n",
" \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n",
" \"state\": \"LOOPING_IN\",\n",
" \"swap_hash\": \"\",\n",
" \"value\": \"500000\"\n",
" }\n",
" ]\n",
"}\n"
]
}
},
{
"time_ms": 1078,
"kind": "exit",
"data": {}
}
]
}

View file

@ -555,10 +555,30 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
}
}
// Static address loop-in store setup is needed by the notification
// manager so confirmation-risk decisions are durable before fan-out.
staticAddressLoopInStore := loopin.NewSqlStore(
loopdb.NewTypedStore[loopin.Querier](baseDb),
clock.NewDefaultClock(), d.lnd.ChainParams,
)
// Start the notification manager.
notificationCfg := &notifications.Config{
Client: loop_swaprpc.NewSwapServerClient(swapClient.Conn),
CurrentToken: swapClient.L402Store.CurrentToken,
PersistStaticLoopInRiskDecision: func(ctx context.Context,
swapHash lntypes.Hash, accepted bool) error {
decision := loopin.ConfirmationRiskDecisionRejected
if accepted {
decision = loopin.ConfirmationRiskDecisionAccepted
}
return staticAddressLoopInStore.
RecordStaticAddressRiskDecision(
ctx, swapHash, decision,
)
},
}
notificationManager := notifications.NewManager(notificationCfg)
@ -662,12 +682,6 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
}
openChannelManager = openchannel.NewManager(openChannelCfg)
// Static address loop-in manager setup.
staticAddressLoopInStore := loopin.NewSqlStore(
loopdb.NewTypedStore[loopin.Querier](baseDb),
clock.NewDefaultClock(), d.lnd.ChainParams,
)
// Run the deposit swap hash migration.
err = loopin.MigrateDepositSwapHash(
d.mainCtx, swapDb, depositStore, staticAddressLoopInStore,

View file

@ -96,7 +96,7 @@ type swapClientServer struct {
reservationManager *reservation.Manager
instantOutManager *instantout.Manager
staticAddressManager *address.Manager
depositManager *deposit.Manager
depositManager staticAddressDepositManager
withdrawalManager *withdraw.Manager
staticLoopInManager *loopin.Manager
openChannelManager *openchannel.Manager
@ -112,6 +112,31 @@ type swapClientServer struct {
stopDaemon func()
}
// staticAddressDepositManager is the deposit manager behavior required by the
// RPC server.
type staticAddressDepositManager interface {
// EnsureDepositsFresh reconciles tracked deposits with lnd's current
// wallet view before user-facing deposit selection.
EnsureDepositsFresh(context.Context) error
// GetActiveDepositsInState returns active deposits that are currently in
// the requested state.
GetActiveDepositsInState(fsm.StateType) ([]*deposit.Deposit, error)
// DepositsForOutpoints returns known deposit records for the requested
// outpoints, optionally skipping unknown outpoints.
DepositsForOutpoints(context.Context, []string, bool) (
[]*deposit.Deposit, error)
// GetVisibleDeposits returns deposits that should be shown in normal
// user-facing views.
GetVisibleDeposits(context.Context) ([]*deposit.Deposit, error)
// GetAllDeposits returns all known deposit records, including historical
// records that are no longer user-visible.
GetAllDeposits(context.Context) ([]*deposit.Deposit, error)
}
// LoopOut initiates a loop out swap with the given parameters. The call returns
// after the swap has been set up with the swap server. From that point onwards,
// progress can be tracked via the LoopOutStatus stream that is returned from
@ -921,6 +946,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
// number of deposits to quote for.
numDeposits := 0
if autoSelectDeposits {
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, err := s.depositManager.GetActiveDepositsInState(
deposit.Deposited,
)
@ -955,6 +986,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
numDeposits = len(selectedDeposits)
} else if len(req.DepositOutpoints) > 0 {
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
// If deposits are selected, we need to retrieve them to
// calculate the total value which we request a quote for.
depositList, err := s.ListStaticAddressDeposits(
@ -977,15 +1014,24 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
return nil, fmt.Errorf("expected %d deposits, got %d",
len(req.DepositOutpoints),
len(depositList.FilteredDeposits))
} else {
numDeposits = len(depositList.FilteredDeposits)
}
numDeposits = len(depositList.FilteredDeposits)
// In case we quote for deposits, we send the server both the
// selected value and the number of deposits. This is so the
// server can probe the selected value and calculate the per
// input fee.
for _, deposit := range depositList.FilteredDeposits {
// ListStaticAddressDeposits only returns deposits that are visible
// in the manager's live view. For a manual quote we additionally
// require the current state to be Deposited so stale client-side
// outpoint selection fails early instead of making it to swap
// initiation.
if deposit.State != looprpc.DepositState_DEPOSITED {
return nil, fmt.Errorf("deposit %s is not "+
"currently available", deposit.Outpoint)
}
totalDepositAmount += btcutil.Amount(
deposit.Value,
)
@ -1693,58 +1739,40 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
}
// ListUnspentRaw returns the unspent wallet view of the backing lnd
// wallet. It might be that deposits show up there that are actually
// not spendable because they already have been used but not yet spent
// by the server. We filter out such deposits here.
// wallet. Static loop-in initiation requires an active deposit record,
// so only deposits that are both wallet-visible and tracked as
// Deposited are returned here.
var (
outpoints []string
isUnspent = make(map[wire.OutPoint]struct{})
)
// Keep track of confirmed outpoints that we need to check against our
// database.
confirmedToCheck := make(map[wire.OutPoint]struct{})
for _, utxo := range utxos {
if utxo.Confirmations < deposit.MinConfs {
// Unconfirmed deposits are always available.
isUnspent[utxo.OutPoint] = struct{}{}
} else {
// Confirmed deposits need to be checked.
outpoints = append(outpoints, utxo.OutPoint.String())
confirmedToCheck[utxo.OutPoint] = struct{}{}
}
outpoints = append(outpoints, utxo.OutPoint.String())
}
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, err
}
// Check the spent status of the deposits by looking at their states.
ignoreUnknownOutpoints := false
ignoreUnknownOutpoints := true
deposits, err := s.depositManager.DepositsForOutpoints(
ctx, outpoints, ignoreUnknownOutpoints,
)
if err != nil {
return nil, err
}
for _, d := range deposits {
// A nil deposit means we don't have a record for it. We'll
// handle this case after the loop.
if d == nil {
continue
}
// If the deposit is in the "Deposited" state, it's available.
if d.IsInState(deposit.Deposited) {
isUnspent[d.OutPoint] = struct{}{}
}
// We have a record for this deposit, so we no longer need to
// check it.
delete(confirmedToCheck, d.OutPoint)
}
// Any remaining outpoints in confirmedToCheck are ones that lnd knows
// about but we don't. These are new, unspent deposits.
for op := range confirmedToCheck {
isUnspent[op] = struct{}{}
}
// Prepare the list of unspent deposits for the rpc response.
@ -1784,6 +1812,12 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
return nil, fmt.Errorf("must select either all or some utxos")
case isAllSelected:
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, err := s.depositManager.GetActiveDepositsInState(
deposit.Deposited,
)
@ -1791,8 +1825,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:
@ -1815,6 +1850,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
}, err
}
// 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.GetConfirmationHeight() <= 0 {
return nil, fmt.Errorf("can't withdraw all deposits while " +
"some deposits are unconfirmed")
}
outpoints = append(outpoints, d.OutPoint)
}
return outpoints, nil
}
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
// deposits behind the static address and displays properties like value,
// state or blocks til expiry.
@ -1830,7 +1884,7 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
"outpoints")
}
allDeposits, err := s.depositManager.GetAllDeposits(ctx)
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
if err != nil {
return nil, err
}
@ -1988,9 +2042,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
for _, d := range ds {
state := toClientDepositState(d.GetState())
confirmationHeight := d.GetConfirmationHeight()
blocksUntilExpiry := confirmationHeight +
int64(addrParams.Expiry) -
int64(lndInfo.BlockHeight)
blocksUntilExpiry := depositBlocksUntilExpiry(
confirmationHeight, addrParams.Expiry,
int64(lndInfo.BlockHeight),
)
pd := &looprpc.Deposit{
Id: d.ID[:],
@ -2066,7 +2121,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
_ *looprpc.StaticAddressSummaryRequest) (
*looprpc.StaticAddressSummaryResponse, error) {
allDeposits, err := s.depositManager.GetAllDeposits(ctx)
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
if err != nil {
return nil, err
}
@ -2082,23 +2137,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
htlcTimeoutSwept int64
)
// Value unconfirmed.
utxos, err := s.staticAddressManager.ListUnspent(
ctx, 0, deposit.MinConfs-1,
)
if err != nil {
return nil, err
}
for _, u := range utxos {
valueUnconfirmed += int64(u.Value)
}
// Confirmed total values by category.
// Total values by category.
for _, d := range allDeposits {
value := int64(d.Value)
switch d.GetState() {
case deposit.Deposited:
valueDeposited += value
if d.GetConfirmationHeight() <= 0 {
valueUnconfirmed += value
} else {
valueDeposited += value
}
case deposit.Expired:
valueExpired += value
@ -2248,13 +2296,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
return err
}
for i := range len(deposits) {
deposits[i].BlocksUntilExpiry =
deposits[i].ConfirmationHeight +
int64(params.Expiry) - bestBlockHeight
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
deposits[i].ConfirmationHeight, params.Expiry,
bestBlockHeight,
)
}
return nil
}
// depositBlocksUntilExpiry returns the remaining blocks until a deposit
// expires. Unconfirmed deposits return the full CSV value because the timeout
// has not started yet.
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
bestBlockHeight int64) int64 {
if confirmationHeight <= 0 {
return int64(expiry)
}
return confirmationHeight + int64(expiry) - bestBlockHeight
}
// StaticOpenChannel initiates an open channel request using static address
// deposits.
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,

View file

@ -0,0 +1,86 @@
package loopd
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.
func TestDepositBlocksUntilExpiry(t *testing.T) {
t.Run("unconfirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(0, 144, 500); blocks != 144 {
t.Fatalf("expected 144 blocks for unconfirmed deposit, got %d",
blocks)
}
})
t.Run("confirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(450, 144, 500); blocks != 94 {
t.Fatalf("expected 94 blocks until expiry, got %d",
blocks)
}
})
}
// 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

@ -0,0 +1,238 @@
package loopd
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
mock_lnd "github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)
type staticAddrDepositStore struct {
allDeposits []*deposit.Deposit
byOutpoint map[string]*deposit.Deposit
}
// CreateDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) CreateDeposit(context.Context,
*deposit.Deposit) error {
return nil
}
// UpdateDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) UpdateDeposit(context.Context,
*deposit.Deposit) error {
return nil
}
// GetDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) GetDeposit(context.Context,
deposit.ID) (*deposit.Deposit, error) {
return nil, nil
}
// DepositForOutpoint returns the deposit for the requested outpoint.
func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context,
outpoint string) (*deposit.Deposit, error) {
if deposit, ok := s.byOutpoint[outpoint]; ok {
return deposit, nil
}
return nil, deposit.ErrDepositNotFound
}
// AllDeposits returns all deposits seeded into the test store.
func (s *staticAddrDepositStore) AllDeposits(context.Context) (
[]*deposit.Deposit, error) {
return s.allDeposits, nil
}
type staticAddrTestAddressManager struct{}
func (s *staticAddrTestAddressManager) GetStaticAddressParameters(
context.Context) (*script.Parameters, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) GetStaticAddress(
context.Context) (*script.StaticAddress, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) ListUnspent(context.Context,
int32, int32) ([]*lnwallet.Utxo, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) GetTaprootAddress(
*btcec.PublicKey, *btcec.PublicKey, int64) (*btcutil.AddressTaproot,
error) {
return nil, nil
}
// newTestDepositManager creates a deposit manager backed by seeded deposits.
func newTestDepositManager(
deposits ...*deposit.Deposit) *deposit.Manager {
byOutpoint := make(map[string]*deposit.Deposit, len(deposits))
for _, deposit := range deposits {
byOutpoint[deposit.OutPoint.String()] = deposit
}
return deposit.NewManager(&deposit.ManagerConfig{
AddressManager: &staticAddrTestAddressManager{},
Store: &staticAddrDepositStore{
allDeposits: deposits,
byOutpoint: byOutpoint,
},
})
}
// newTestStaticAddressContext creates static address test dependencies.
func newTestStaticAddressContext(t *testing.T) (*address.Manager,
*mock_lnd.LndMockServices) {
t.Helper()
mock := mock_lnd.NewMockLnd()
_, client := mock_lnd.CreateKey(1)
_, server := mock_lnd.CreateKey(2)
addrStore := &mockAddressStore{
params: []*script.Parameters{{
ClientPubkey: client,
ServerPubkey: server,
Expiry: 10,
PkScript: []byte("pkscript"),
}},
}
addrMgr, err := address.NewManager(&address.ManagerConfig{
Store: addrStore,
WalletKit: mock.WalletKit,
ChainParams: mock.ChainParams,
}, 1)
require.NoError(t, err)
return addrMgr, mock
}
// TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit
// listings include visible deposit records.
func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) {
t.Parallel()
available := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
}
available.SetState(deposit.Deposited)
addrMgr, lnd := newTestStaticAddressContext(t)
server := &swapClientServer{
depositManager: newTestDepositManager(available),
staticAddressManager: addrMgr,
lnd: &lnd.LndServices,
}
resp, err := server.ListStaticAddressDeposits(
context.Background(), &looprpc.ListStaticAddressDepositsRequest{},
)
require.NoError(t, err)
require.Len(t, resp.FilteredDeposits, 1)
require.Equal(
t, available.OutPoint.String(),
resp.FilteredDeposits[0].Outpoint,
)
}
// TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are
// included in static address summary totals.
func TestGetStaticAddressSummaryTotalsDeposits(t *testing.T) {
t.Parallel()
unconfirmed := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
},
Value: btcutil.Amount(2_000),
ConfirmationHeight: 0,
}
unconfirmed.SetState(deposit.Deposited)
confirmed := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{5},
Index: 5,
},
Value: btcutil.Amount(3_000),
ConfirmationHeight: 123,
}
confirmed.SetState(deposit.Deposited)
addrMgr, _ := newTestStaticAddressContext(t)
server := &swapClientServer{
depositManager: newTestDepositManager(
unconfirmed, confirmed,
),
staticAddressManager: addrMgr,
}
resp, err := server.GetStaticAddressSummary(
context.Background(), &looprpc.StaticAddressSummaryRequest{},
)
require.NoError(t, err)
require.EqualValues(t, 2, resp.TotalNumDeposits)
require.EqualValues(t, 2_000, resp.ValueUnconfirmedSatoshis)
require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis)
}
// TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote
// requests fail for selected deposits that are no longer available.
func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) {
t.Parallel()
setLogger(btclog.Disabled)
locked := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{6},
Index: 6,
},
Value: btcutil.Amount(5_000),
}
locked.SetState(deposit.LoopingIn)
addrMgr, lnd := newTestStaticAddressContext(t)
server := &swapClientServer{
depositManager: newTestDepositManager(locked),
staticAddressManager: addrMgr,
lnd: &lnd.LndServices,
}
_, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{
DepositOutpoints: []string{locked.OutPoint.String()},
})
require.ErrorContains(t, err, "is not currently available")
}

View file

@ -2,6 +2,7 @@ package loopd
import (
"context"
"fmt"
"os"
"testing"
"time"
@ -535,6 +536,14 @@ func (s *mockStaticAddressLoopInStore) IsStored(_ context.Context,
return false, nil
}
// RecordStaticAddressRiskDecision satisfies the static loop-in store interface.
func (s *mockStaticAddressLoopInStore) RecordStaticAddressRiskDecision(
_ context.Context, _ lntypes.Hash,
_ loopin.ConfirmationRiskDecision) error {
return nil
}
// GetLoopInByHash returns the configured loop-in with the given hash.
func (s *mockStaticAddressLoopInStore) GetLoopInByHash(_ context.Context,
swapHash lntypes.Hash) (*loopin.StaticAddressLoopIn, error) {
@ -1321,7 +1330,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
if d, ok := s.byOutpoint[outpoint]; ok {
return d, nil
}
return nil, nil
return nil, deposit.ErrDepositNotFound
}
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
@ -1335,6 +1344,90 @@ func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
return deposits, nil
}
// listUnspentDepositManager backs ListUnspentDeposits tests without requiring
// the full deposit manager event loop.
type listUnspentDepositManager struct {
byOutpoint map[string]*deposit.Deposit
ensureDepositsFreshCalls int
onEnsureDepositsFresh func(*listUnspentDepositManager)
}
func (m *listUnspentDepositManager) EnsureDepositsFresh(
context.Context) error {
m.ensureDepositsFreshCalls++
if m.onEnsureDepositsFresh != nil {
m.onEnsureDepositsFresh(m)
}
return nil
}
func (m *listUnspentDepositManager) GetActiveDepositsInState(
state fsm.StateType) ([]*deposit.Deposit, error) {
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
for _, d := range m.byOutpoint {
if !d.IsInState(state) {
continue
}
deposits = append(deposits, d)
}
return deposits, nil
}
func (m *listUnspentDepositManager) DepositsForOutpoints(
_ context.Context, outpoints []string, ignoreUnknown bool) (
[]*deposit.Deposit, error) {
deposits := make([]*deposit.Deposit, 0, len(outpoints))
seen := make(map[string]struct{}, len(outpoints))
for i, outpoint := range outpoints {
if _, ok := seen[outpoint]; ok {
return nil, fmt.Errorf("duplicate outpoint %s "+
"at index %d", outpoint, i)
}
seen[outpoint] = struct{}{}
d, ok := m.byOutpoint[outpoint]
if !ok {
if ignoreUnknown {
continue
}
return nil, deposit.ErrDepositNotFound
}
deposits = append(deposits, d)
}
return deposits, nil
}
func (m *listUnspentDepositManager) GetVisibleDeposits(
context.Context) ([]*deposit.Deposit, error) {
return m.allDeposits(), nil
}
func (m *listUnspentDepositManager) GetAllDeposits(
context.Context) ([]*deposit.Deposit, error) {
return m.allDeposits(), nil
}
func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit {
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
for _, d := range m.byOutpoint {
deposits = append(deposits, d)
}
return deposits
}
// TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits.
func TestListUnspentDeposits(t *testing.T) {
ctx := context.Background()
@ -1376,39 +1469,41 @@ func TestListUnspentDeposits(t *testing.T) {
}
}
minConfs := int64(deposit.MinConfs)
utxoBelow := makeUtxo(0, minConfs-1) // always included
utxoAt := makeUtxo(1, minConfs) // included only if Deposited
utxoAbove1 := makeUtxo(2, minConfs+1)
utxoAbove2 := makeUtxo(3, minConfs+2)
utxoUnknown := makeUtxo(0, 0)
utxoDeposited := makeUtxo(1, 1)
utxoWithdrawn := makeUtxo(2, 2)
utxoLoopingIn := makeUtxo(3, 5)
utxoConfirmedUnknown := makeUtxo(4, 3)
// Helper to build the deposit manager with specific states.
buildDepositMgr := func(
states map[wire.OutPoint]fsm.StateType) *deposit.Manager {
states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager {
store := &mockDepositStore{
depMgr := &listUnspentDepositManager{
byOutpoint: make(map[string]*deposit.Deposit),
}
for op, state := range states {
d := &deposit.Deposit{OutPoint: op}
d.SetState(state)
store.byOutpoint[op.String()] = d
depMgr.byOutpoint[op.String()] = d
}
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
return depMgr
}
// Include below-min-conf and >=min with Deposited; exclude others.
t.Run("below min conf always, Deposited included, others excluded",
// Only known Deposited records are available. Unknown deposits and
// known non-Deposited states are excluded.
t.Run("only known Deposited included",
func(t *testing.T) {
mock.SetListUnspent([]*lnwallet.Utxo{
utxoBelow, utxoAt, utxoAbove1, utxoAbove2,
utxoUnknown, utxoDeposited, utxoWithdrawn,
utxoLoopingIn,
})
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
utxoAt.OutPoint: deposit.Deposited,
utxoAbove1.OutPoint: deposit.Withdrawn,
utxoAbove2.OutPoint: deposit.LoopingIn,
utxoDeposited.OutPoint: deposit.Deposited,
utxoWithdrawn.OutPoint: deposit.Withdrawn,
utxoLoopingIn.OutPoint: deposit.LoopingIn,
})
server := &swapClientServer{
@ -1420,9 +1515,10 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
// Expect utxoBelow and utxoAt only.
require.Len(t, resp.Utxos, 2)
// Expect the Deposited utxo only.
require.Len(t, resp.Utxos, 1)
got := map[string]struct{}{}
for _, u := range resp.Utxos {
got[u.Outpoint] = struct{}{}
@ -1430,25 +1526,23 @@ func TestListUnspentDeposits(t *testing.T) {
// same across utxos.
require.NotEmpty(t, u.StaticAddress)
}
_, ok1 := got[utxoBelow.OutPoint.String()]
_, ok2 := got[utxoAt.OutPoint.String()]
require.True(t, ok1)
require.True(t, ok2)
_, ok := got[utxoDeposited.OutPoint.String()]
require.True(t, ok)
})
// Swap states, now include utxoBelow and utxoAbove1.
t.Run("Deposited on >=min included; non-Deposited excluded",
// Confirmation depth no longer changes availability; state does.
t.Run("availability ignores conf depth once deposit state is known",
func(t *testing.T) {
mock.SetListUnspent(
[]*lnwallet.Utxo{
utxoBelow, utxoAt, utxoAbove1,
utxoAbove2,
utxoUnknown, utxoDeposited,
utxoWithdrawn, utxoLoopingIn,
})
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
utxoAt.OutPoint: deposit.Withdrawn,
utxoAbove1.OutPoint: deposit.Deposited,
utxoAbove2.OutPoint: deposit.Withdrawn,
utxoDeposited.OutPoint: deposit.Deposited,
utxoWithdrawn.OutPoint: deposit.Withdrawn,
utxoLoopingIn.OutPoint: deposit.LoopingIn,
})
server := &swapClientServer{
@ -1460,26 +1554,32 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
require.Len(t, resp.Utxos, 2)
require.Len(t, resp.Utxos, 1)
got := map[string]struct{}{}
for _, u := range resp.Utxos {
got[u.Outpoint] = struct{}{}
}
_, ok1 := got[utxoBelow.OutPoint.String()]
_, ok2 := got[utxoAbove1.OutPoint.String()]
require.True(t, ok1)
require.True(t, ok2)
_, ok := got[utxoDeposited.OutPoint.String()]
require.True(t, ok)
})
// Confirmed UTXO not present in store should be included.
t.Run("confirmed utxo not in store is included", func(t *testing.T) {
// Only return a confirmed UTXO from lnd and make sure the
// deposit manager/store doesn't know about it.
mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2})
// A wallet-visible UTXO reconciled by EnsureDepositsFresh should be
// returned in the same ListUnspentDeposits call.
t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) {
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
// Empty store (no states for any outpoint).
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
depMgr.onEnsureDepositsFresh = func(
m *listUnspentDepositManager) {
d := &deposit.Deposit{
OutPoint: utxoConfirmedUnknown.OutPoint,
}
d.SetState(deposit.Deposited)
m.byOutpoint[d.OutPoint.String()] = d
}
server := &swapClientServer{
staticAddressManager: addrMgr,
@ -1490,13 +1590,12 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
// We expect the confirmed UTXO to be included even though it
// doesn't exist in the store yet.
require.Len(t, resp.Utxos, 1)
require.Equal(
t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint,
t, utxoConfirmedUnknown.OutPoint.String(),
resp.Utxos[0].Outpoint,
)
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
})
}

View file

@ -0,0 +1,3 @@
-- Drop confirmation-risk decision fields from static address loop-ins.
ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision;
ALTER TABLE static_address_swaps DROP COLUMN confirmation_risk_decision_time;

View file

@ -0,0 +1,15 @@
-- confirmation_risk_decision records the server's confirmation-risk decision
-- for a static address loop-in. Possible values are:
-- - '': no decision has been received yet;
-- - 'accepted': the server accepted waiting for the low-confirmation
-- deposits, which starts or reconstructs the payment deadline;
-- - 'rejected': the server stopped waiting for the low-confirmation deposits
-- before paying the invoice.
-- Once rejected, a later accepted update is ignored.
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT '';
-- confirmation_risk_decision_time records when loopd received and persisted
-- the server's decision, so payment deadlines can be reconstructed after
-- restart. Same-decision replays preserve the original timestamp; changing
-- from accepted to rejected updates it.
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP;

View file

@ -137,18 +137,20 @@ type StaticAddress struct {
}
type StaticAddressSwap struct {
ID int32
SwapHash []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ID int32
SwapHash []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
}
type StaticAddressSwapUpdate struct {

View file

@ -67,6 +67,7 @@ type Querier interface {
MapDepositToSwap(ctx context.Context, arg MapDepositToSwapParams) error
OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSelectedSwapAmountParams) error
OverrideSwapCosts(ctx context.Context, arg OverrideSwapCostsParams) error
RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error
SwapHashForDepositID(ctx context.Context, depositID []byte) ([]byte, error)
UpdateBatch(ctx context.Context, arg UpdateBatchParams) error
UpdateDeposit(ctx context.Context, arg UpdateDepositParams) error

View file

@ -33,6 +33,22 @@ SET
WHERE
swap_hash = $1;
-- name: RecordStaticAddressRiskDecision :exec
UPDATE static_address_swaps
SET
confirmation_risk_decision = $2,
confirmation_risk_decision_time = CASE
WHEN confirmation_risk_decision = $2 THEN
COALESCE(confirmation_risk_decision_time, $3)
ELSE $3
END
WHERE
swap_hash = $1
AND NOT (
confirmation_risk_decision = 'rejected'
AND $2 = 'accepted'
);
-- name: InsertStaticAddressMetaUpdate :exec
INSERT INTO static_address_swap_updates (
swap_hash,
@ -147,7 +163,3 @@ WHERE
d.swap_hash = $1;

View file

@ -153,7 +153,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]
const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one
SELECT
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
FROM
swaps
@ -166,36 +166,38 @@ WHERE
`
type GetStaticAddressLoopInSwapRow struct {
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
}
func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) {
@ -225,6 +227,8 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
&i.HtlcTimeoutSweepAddress,
&i.SelectedAmount,
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -239,7 +243,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many
SELECT
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
FROM
swaps
@ -263,36 +267,38 @@ ORDER BY
`
type GetStaticAddressLoopInSwapsByStatesRow struct {
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
ID int32
SwapHash []byte
Preimage []byte
InitiationTime time.Time
AmountRequested int64
CltvExpiry int32
MaxMinerFee int64
MaxSwapFee int64
InitiationHeight int32
ProtocolVersion int32
Label string
ID_2 int32
SwapHash_2 []byte
SwapInvoice string
LastHop []byte
PaymentTimeoutSeconds int32
QuotedSwapFeeSatoshis int64
DepositOutpoints string
HtlcTxFeeRateSatKw int64
HtlcTimeoutSweepTxID sql.NullString
HtlcTimeoutSweepAddress string
SelectedAmount int64
Fast bool
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
SwapHash_3 []byte
SenderScriptPubkey []byte
ReceiverScriptPubkey []byte
SenderInternalPubkey []byte
ReceiverInternalPubkey []byte
ClientKeyFamily int32
ClientKeyIndex int32
}
func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) {
@ -328,6 +334,8 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
&i.HtlcTimeoutSweepAddress,
&i.SelectedAmount,
&i.Fast,
&i.ConfirmationRiskDecision,
&i.ConfirmationRiskDecisionTime,
&i.SwapHash_3,
&i.SenderScriptPubkey,
&i.ReceiverScriptPubkey,
@ -482,6 +490,34 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe
return err
}
const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec
UPDATE static_address_swaps
SET
confirmation_risk_decision = $2,
confirmation_risk_decision_time = CASE
WHEN confirmation_risk_decision = $2 THEN
COALESCE(confirmation_risk_decision_time, $3)
ELSE $3
END
WHERE
swap_hash = $1
AND NOT (
confirmation_risk_decision = 'rejected'
AND $2 = 'accepted'
)
`
type RecordStaticAddressRiskDecisionParams struct {
SwapHash []byte
ConfirmationRiskDecision string
ConfirmationRiskDecisionTime sql.NullTime
}
func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error {
_, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime)
return err
}
const swapHashForDepositID = `-- name: SwapHashForDepositID :one
SELECT
swap_hash

View file

@ -26,6 +26,14 @@ const (
// static loop in sweep requests.
NotificationTypeStaticLoopInSweepRequest
// NotificationTypeStaticLoopInRiskAccepted is the notification type for
// static loop in confirmation risk acceptance.
NotificationTypeStaticLoopInRiskAccepted
// NotificationTypeStaticLoopInRiskRejected is the notification type for
// static loop in confirmation risk rejection.
NotificationTypeStaticLoopInRiskRejected
// NotificationTypeUnfinishedSwap is the notification type for unfinished
// swap notifications.
NotificationTypeUnfinishedSwap
@ -80,6 +88,13 @@ type Config struct {
// MaxQueuedNotifications is the maximum number of notifications that
// can wait in each subscriber's delivery queue.
MaxQueuedNotifications int
// PersistStaticLoopInRiskDecision durably records static loop-in
// confirmation-risk decisions. If this fails, the notification is still
// cached and forwarded so a later subscriber can process it after the swap
// row exists.
PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash,
bool) error
}
// Manager is a manager for notifications that the swap server sends to the
@ -91,7 +106,26 @@ type Manager struct {
hasL402 bool
// subscribers holds active notification subscribers by notification
// type. It is guarded by the Manager mutex.
subscribers map[NotificationType][]subscriber
// staticLoopInRiskAccepted caches accepted risk decisions by swap hash
// so a later matching subscriber can receive a previously delivered
// server decision.
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification
// staticLoopInRiskRejected caches rejected risk decisions by swap hash
// so a later matching subscriber can receive a previously delivered
// server decision.
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification
// staticLoopInRiskPersisted records whether the cached risk decision for
// a swap hash was durably persisted. Unpersisted decisions remain cached
// after subscriber cancellation so they can be replayed.
staticLoopInRiskPersisted map[lntypes.Hash]bool
}
// NewManager creates a new notification manager.
@ -107,12 +141,22 @@ func NewManager(cfg *Config) *Manager {
return &Manager{
cfg: cfg,
subscribers: make(map[NotificationType][]subscriber),
staticLoopInRiskAccepted: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification,
),
staticLoopInRiskRejected: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification,
),
staticLoopInRiskPersisted: make(map[lntypes.Hash]bool),
}
}
type subscriber struct {
subCtx context.Context
recvChan any
swapHash *lntypes.Hash
enqueue func(any)
}
@ -222,6 +266,19 @@ func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
}
}
// dropNotification sends a best-effort notification to a subscriber.
func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T,
description string) {
select {
case recvChan <- ntfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping %s notification for slow subscriber",
description)
}
}
// SubscribeReservations subscribes to the reservation notifications.
func (m *Manager) SubscribeReservations(ctx context.Context,
) <-chan *swapserverrpc.ServerReservationNotification {
@ -271,6 +328,68 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
return notifChan
}
func subscribeStaticLoopInRiskDecision[T any](m *Manager, ctx context.Context,
swapHash lntypes.Hash, notifType NotificationType,
notifications map[lntypes.Hash]T) <-chan T {
notifChan := make(chan T, 1)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[notifType] = append(m.subscribers[notifType], sub)
if ntfn, ok := notifications[swapHash]; ok {
notifChan <- ntfn
if m.staticLoopInRiskPersisted[swapHash] {
delete(notifications, swapHash)
delete(m.staticLoopInRiskPersisted, swapHash)
}
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(notifType, sub)
m.Lock()
if _, ok := notifications[swapHash]; ok &&
m.staticLoopInRiskPersisted[swapHash] {
delete(notifications, swapHash)
delete(m.staticLoopInRiskPersisted, swapHash)
}
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
return subscribeStaticLoopInRiskDecision(
m, ctx, swapHash, NotificationTypeStaticLoopInRiskAccepted,
m.staticLoopInRiskAccepted,
)
}
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
return subscribeStaticLoopInRiskDecision(
m, ctx, swapHash, NotificationTypeStaticLoopInRiskRejected,
m.staticLoopInRiskRejected,
)
}
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
@ -428,7 +547,7 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error {
notification, err := notifStream.Recv()
if err == nil && notification != nil {
log.Tracef("Received notification: %v", notification)
m.handleNotification(notification)
m.handleNotification(ctx, notification)
continue
}
@ -438,9 +557,73 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error {
}
}
// staticLoopInRiskDecisionName returns the log label for a risk decision.
func staticLoopInRiskDecisionName(accepted bool) string {
if accepted {
return "accepted"
}
return "rejected"
}
// handleStaticLoopInRiskDecision persists, caches, and forwards a risk
// decision notification to the matching subscriber.
func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context,
swapHashBytes []byte, accepted bool, notifType NotificationType,
cacheDecision func(lntypes.Hash, bool),
notifySubscriber func(subscriber)) {
decision := staticLoopInRiskDecisionName(accepted)
persisted := m.cfg.PersistStaticLoopInRiskDecision == nil
var (
swapHash lntypes.Hash
hasSwapHash bool
)
if swapHashBytes != nil {
hash, err := lntypes.MakeHash(swapHashBytes)
if err != nil {
log.Warnf("Received invalid static loop in risk "+
"%s notification: %v", decision, err)
} else {
swapHash = hash
hasSwapHash = true
}
}
if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil {
err := m.cfg.PersistStaticLoopInRiskDecision(
ctx, swapHash, accepted,
)
if err != nil {
log.Errorf("Unable to persist static loop in risk "+
"%s notification: %v", decision, err)
} else {
persisted = true
}
}
m.Lock()
defer m.Unlock()
if hasSwapHash {
cacheDecision(swapHash, persisted)
}
for _, sub := range m.subscribers[notifType] {
if !hasSwapHash || sub.swapHash == nil ||
*sub.swapHash != swapHash {
continue
}
notifySubscriber(sub)
}
}
// handleNotification handles an incoming notification from the server,
// forwarding it to the appropriate subscribers.
func (m *Manager) handleNotification(ntfn *swapserverrpc.
func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc.
SubscribeNotificationsResponse) {
switch ntfn.Notification.(type) {
@ -476,6 +659,62 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn)
}
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
// We'll forward the static loop in risk accepted notification to the
// subscriber for the matching swap.
riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
var swapHashBytes []byte
if riskAcceptedNtfn != nil {
swapHashBytes = riskAcceptedNtfn.SwapHash
}
m.handleStaticLoopInRiskDecision(
ctx, swapHashBytes, true,
NotificationTypeStaticLoopInRiskAccepted,
func(swapHash lntypes.Hash, persisted bool) {
m.staticLoopInRiskAccepted[swapHash] =
riskAcceptedNtfn
m.staticLoopInRiskPersisted[swapHash] = persisted
delete(m.staticLoopInRiskRejected, swapHash)
},
func(sub subscriber) {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification)
dropNotification(
sub, recvChan, riskAcceptedNtfn,
"static loop in risk accepted",
)
},
)
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll
// We'll forward the static loop in risk rejected notification to the
// subscriber for the matching swap.
riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected()
var swapHashBytes []byte
if riskRejectedNtfn != nil {
swapHashBytes = riskRejectedNtfn.SwapHash
}
m.handleStaticLoopInRiskDecision(
ctx, swapHashBytes, false,
NotificationTypeStaticLoopInRiskRejected,
func(swapHash lntypes.Hash, persisted bool) {
m.staticLoopInRiskRejected[swapHash] =
riskRejectedNtfn
m.staticLoopInRiskPersisted[swapHash] = persisted
delete(m.staticLoopInRiskAccepted, swapHash)
},
func(sub subscriber) {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification)
dropNotification(
sub, recvChan, riskRejectedNtfn,
"static loop in risk rejected",
)
},
)
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
// We'll forward the unfinished swap notification to all
// subscribers.

View file

@ -220,6 +220,89 @@ func staticLoopInSweepNotification(
}
}
// staticLoopInRiskAcceptedNotification builds a risk accepted notification.
func staticLoopInRiskAcceptedNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
}
}
// staticLoopInRiskRejectedNotification builds a risk rejected notification.
func staticLoopInRiskRejectedNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
}
}
type staticLoopInRiskNotification interface {
GetSwapHash() []byte
}
// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout.
func assertStaticLoopInRiskNotificationSwapScoped[
T staticLoopInRiskNotification](t *testing.T,
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse, label string,
swapHashA, swapHashB lntypes.Hash) {
t.Helper()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChanA := subscribe(mgr, subCtx, swapHashA)
subChanB := subscribe(mgr, subCtx, swapHashB)
mgr.handleNotification(t.Context(), notification(swapHashA))
select {
case received := <-subChanA:
require.Equal(t, swapHashA[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatalf("did not receive first swap risk %s notification",
label)
}
select {
case received := <-subChanB:
t.Fatalf("second swap received wrong notification: %x",
received.GetSwapHash())
default:
}
mgr.handleNotification(t.Context(), notification(swapHashB))
select {
case received := <-subChanB:
require.Equal(t, swapHashB[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatalf("did not receive second swap risk %s notification",
label)
}
}
// TestManager_SlowReservationSubscriberDoesNotBlock tests that a reservation
// subscriber with a full notification channel does not block delivery to other
// subscribers. Reservation notifications are best-effort, so slow subscribers
@ -238,7 +321,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
fastChan := mgr.SubscribeReservations(fastCtx)
firstNotif := getTestNotification(testReservationId)
mgr.handleNotification(firstNotif)
mgr.handleNotification(t.Context(), firstNotif)
received := <-fastChan
require.Equal(t, testReservationId, received.ReservationId)
@ -246,7 +329,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
secondNotif := getTestNotification(testReservationId2)
done := make(chan struct{})
go func() {
mgr.handleNotification(secondNotif)
mgr.handleNotification(t.Context(), secondNotif)
close(done)
}()
@ -345,7 +428,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
subChan := mgr.SubscribeUnfinishedSwaps(subCtx)
swapHashA := lntypes.Hash{0x21, 0x22}
mgr.handleNotification(unfinishedSwapNotification(swapHashA))
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashA))
require.Eventually(t, func() bool {
return len(subChan) == 1
@ -354,7 +437,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
swapHashB := lntypes.Hash{0x23, 0x24}
done := make(chan struct{})
go func() {
mgr.handleNotification(unfinishedSwapNotification(swapHashB))
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashB))
close(done)
}()
@ -426,11 +509,11 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T,
subChan := subscribe(mgr, subCtx)
mgr.handleNotification(notification(swapHashA))
mgr.handleNotification(t.Context(), notification(swapHashA))
done := make(chan struct{})
go func() {
mgr.handleNotification(notification(swapHashB))
mgr.handleNotification(t.Context(), notification(swapHashB))
close(done)
}()
@ -460,6 +543,345 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T,
}
}
// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
// forwards static loop in risk accepted notifications to subscribers.
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
swapHash := lntypes.Hash{0x04, 0x05}
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
},
)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not receive risk accepted notification")
}
}
// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are
// handed to the durable callback before they are treated as delivered.
func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) {
t.Parallel()
type persistedDecision struct {
swapHash lntypes.Hash
accepted bool
}
persisted := make(chan persistedDecision, 2)
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
swapHash lntypes.Hash, accepted bool) error {
persisted <- persistedDecision{
swapHash: swapHash,
accepted: accepted,
}
return nil
},
})
acceptedHash := lntypes.Hash{0x16, 0x17}
rejectedHash := lntypes.Hash{0x18, 0x19}
mgr.handleNotification(
t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash),
)
mgr.handleNotification(
t.Context(), staticLoopInRiskRejectedNotification(rejectedHash),
)
select {
case decision := <-persisted:
require.Equal(t, acceptedHash, decision.swapHash)
require.True(t, decision.accepted)
case <-time.After(time.Second):
t.Fatal("accepted risk decision was not persisted")
}
select {
case decision := <-persisted:
require.Equal(t, rejectedHash, decision.swapHash)
require.False(t, decision.accepted)
case <-time.After(time.Second):
t.Fatal("rejected risk decision was not persisted")
}
}
// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an
// early risk notification is still cached if the swap row does not exist yet.
func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) {
t.Parallel()
swapHash := lntypes.Hash{0x1a, 0x1b}
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
_ lntypes.Hash, _ bool) error {
return errors.New("swap not stored yet")
},
})
mgr.handleNotification(
t.Context(), staticLoopInRiskAcceptedNotification(swapHash),
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk notification after persist failure")
}
}
// TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel verifies that
// a non-persisted risk decision remains replayable if the subscriber is canceled
// before the FSM has a chance to process it.
func TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t *testing.T) {
t.Parallel()
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t,
(*Manager).SubscribeStaticLoopInRiskAccepted,
staticLoopInRiskAcceptedNotification,
)
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t,
(*Manager).SubscribeStaticLoopInRiskRejected,
staticLoopInRiskRejectedNotification,
)
}
func assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel[
T staticLoopInRiskNotification](t *testing.T,
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse) {
t.Helper()
swapHash := lntypes.Hash{0x2a, 0x2b}
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
_ lntypes.Hash, _ bool) error {
return errors.New("swap not stored yet")
},
})
subCtx, subCancel := context.WithCancel(t.Context())
subChan := subscribe(mgr, subCtx, swapHash)
mgr.handleNotification(t.Context(), notification(swapHash))
require.Eventually(t, func() bool {
return len(subChan) == 1
}, time.Second, 10*time.Millisecond)
subCancel()
select {
case <-subChan:
case <-time.After(time.Second):
t.Fatal("risk decision notification was not delivered before " +
"cancel")
}
select {
case _, ok := <-subChan:
require.False(t, ok)
case <-time.After(time.Second):
t.Fatal("risk decision subscription did not close after cancel")
}
replayCtx, replayCancel := context.WithCancel(t.Context())
defer replayCancel()
replayChan := subscribe(mgr, replayCtx, swapHash)
select {
case received := <-replayChan:
require.Equal(t, swapHash[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatal("cached risk decision was lost after subscriber " +
"cancellation")
}
}
// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) {
t.Parallel()
assertStaticLoopInRiskNotificationSwapScoped(
t, func(m *Manager, ctx context.Context,
swapHash lntypes.Hash) <-chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification {
return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash)
}, staticLoopInRiskAcceptedNotification, "accepted",
lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07},
)
}
// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
// replays a risk accepted notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
swapHash := lntypes.Hash{0x06, 0x07}
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
},
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk accepted notification")
}
}
// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager
// forwards static loop in risk rejected notifications to subscribers.
func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
swapHash := lntypes.Hash{0x08, 0x09}
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
},
)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not receive risk rejected notification")
}
}
// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) {
t.Parallel()
assertStaticLoopInRiskNotificationSwapScoped(
t, func(m *Manager, ctx context.Context,
swapHash lntypes.Hash) <-chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification {
return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash)
}, staticLoopInRiskRejectedNotification, "rejected",
lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b},
)
}
// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager
// replays a risk rejected notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
swapHash := lntypes.Hash{0x0a, 0x0b}
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
},
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk rejected notification")
}
}
// TestManager_Backoff verifies that repeated failures in
// subscribeNotifications cause the Manager to space out subscription attempts
// via a predictable incremental backoff.

View file

@ -52,7 +52,8 @@ type Deposit struct {
Value btcutil.Amount
// ConfirmationHeight is the absolute height at which the deposit was
// first confirmed.
// first confirmed. A value of zero means the deposit is still
// unconfirmed.
ConfirmationHeight int64
// TimeOutSweepPkScript is the pk script that is used to sweep the
@ -91,6 +92,10 @@ func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool {
d.Lock()
defer d.Unlock()
if d.ConfirmationHeight <= 0 {
return false
}
return currentHeight >= uint32(d.ConfirmationHeight)+expiry
}
@ -127,6 +132,12 @@ func (d *Deposit) isInStateNoLock(state fsm.StateType) bool {
return d.state == state
}
// IsInStateNoLock returns whether the deposit is in the given state without
// acquiring the deposit lock.
func (d *Deposit) IsInStateNoLock(state fsm.StateType) bool {
return d.isInStateNoLock(state)
}
// GetConfirmationHeight returns the deposit confirmation height.
func (d *Deposit) GetConfirmationHeight() int64 {
d.Lock()

View file

@ -0,0 +1,17 @@
package deposit
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestDepositIsExpiredUnconfirmed verifies that unconfirmed deposits do not
// expire because their CSV timeout has not started yet.
func TestDepositIsExpiredUnconfirmed(t *testing.T) {
t.Parallel()
d := &Deposit{}
require.False(t, d.IsExpired(1_000, 144))
}

View file

@ -42,8 +42,8 @@ var (
// States.
var (
// Deposited signals that funds at a static address have reached the
// confirmation height.
// Deposited signals that funds at a static address have been detected
// and are available to the client.
Deposited = fsm.StateType("Deposited")
// Withdrawing signals that the withdrawal transaction has been
@ -93,8 +93,8 @@ var (
// Events.
var (
// OnStart is sent to the fsm once the deposit outpoint has been
// sufficiently confirmed. It transitions the fsm into the Deposited
// state from where we can trigger a withdrawal, a loopin or an expiry.
// detected. It transitions the fsm into the Deposited state from where
// we can trigger a withdrawal, a loopin or an expiry.
OnStart = fsm.EventType("OnStart")
// OnWithdrawInitiated is sent to the fsm when a withdrawal has been

View file

@ -6,6 +6,7 @@ import (
"fmt"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/btcsuite/btcd/txscript"
@ -17,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
@ -86,6 +86,9 @@ type Manager struct {
// been finalized. The manager will adjust its internal state and flush
// finalized deposits from its memory.
finalizedDepositChan chan wire.OutPoint
// currentHeight stores the currently best known block height.
currentHeight atomic.Uint32
}
// NewManager creates a new deposit manager.
@ -107,6 +110,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
return err
}
var startupHeight uint32
select {
case height := <-newBlockChan:
startupHeight = uint32(height)
m.currentHeight.Store(startupHeight)
case err = <-newBlockErrChan:
return err
case <-ctx.Done():
return ctx.Err()
}
// Recover previous deposits and static address parameters from the DB.
err = m.recoverDeposits(ctx)
if err != nil {
@ -120,6 +136,14 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
err = m.reconcileDeposits(ctx)
if err != nil {
log.Errorf("unable to reconcile deposits: %v", err)
} else {
// The startup height was consumed before recovered deposit FSMs
// existed. Replay it so already-expired recovered deposits can act
// immediately, but only after their wallet view is fresh.
err = m.notifyActiveDeposits(ctx, startupHeight)
if err != nil {
return err
}
}
// Start the deposit notifier.
@ -132,7 +156,15 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
for {
select {
case height := <-newBlockChan:
err := m.notifyActiveDeposits(ctx, uint32(height))
m.currentHeight.Store(uint32(height))
err := m.reconcileDeposits(ctx)
if err != nil {
log.Errorf("unable to reconcile deposits: %v", err)
continue
}
err = m.notifyActiveDeposits(ctx, uint32(height))
if err != nil {
return err
}
@ -226,8 +258,10 @@ func (m *Manager) recoverDeposits(ctx context.Context) error {
return nil
}
// pollDeposits polls new deposits to our static address and notifies the
// manager's event loop about them.
// pollDeposits periodically polls for new deposits to our static address. This
// complements the block-driven reconciliation in the main event loop: while new
// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker
// here catches deposits that appear in the mempool between blocks.
func (m *Manager) pollDeposits(ctx context.Context) {
log.Debugf("Waiting for new static address deposits...")
@ -250,6 +284,15 @@ func (m *Manager) pollDeposits(ctx context.Context) {
}()
}
// EnsureDepositsFresh reconciles the cached active deposit set with lnd's
// current wallet view. Spending paths call this before selecting deposits so
// stale persisted records are not treated as live funds. This can happen when
// an unconfirmed funding transaction is replaced, a confirmed deposit is
// reorged out, or the output was spent outside the active manager path.
func (m *Manager) EnsureDepositsFresh(ctx context.Context) error {
return m.reconcileDeposits(ctx)
}
// reconcileDeposits fetches all spends to our static addresses from our lnd
// wallet and matches it against the deposits in our memory that we've seen so
// far. It picks the newly identified deposits and starts a state machine per
@ -261,12 +304,24 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
log.Tracef("Reconciling new deposits...")
utxos, err := m.cfg.AddressManager.ListUnspent(
ctx, MinConfs, MaxConfs,
ctx, 0, MaxConfs,
)
if err != nil {
return fmt.Errorf("unable to list new deposits: %w", err)
}
currentHeight := m.currentHeight.Load()
err = m.updateDepositConfirmations(ctx, utxos, currentHeight)
if err != nil {
return fmt.Errorf("unable to update deposit "+
"confirmations: %w", err)
}
err = m.syncActiveDeposits(ctx, utxos)
if err != nil {
return fmt.Errorf("unable to sync active deposits: %w", err)
}
newDeposits := m.filterNewDeposits(utxos)
if len(newDeposits) == 0 {
log.Tracef("No new deposits...")
@ -274,7 +329,7 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
}
for _, utxo := range newDeposits {
deposit, err := m.createNewDeposit(ctx, utxo)
deposit, err := m.createNewDeposit(ctx, utxo, currentHeight)
if err != nil {
return fmt.Errorf("unable to retain new deposit: %w",
err)
@ -294,9 +349,11 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
// it in our database and manager memory.
func (m *Manager) createNewDeposit(ctx context.Context,
utxo *lnwallet.Utxo) (*Deposit, error) {
utxo *lnwallet.Utxo, currentHeight uint32) (*Deposit, error) {
blockHeight, err := m.getBlockHeight(ctx, utxo)
confirmationHeight, err := confirmationHeightForUtxo(
currentHeight, utxo,
)
if err != nil {
return nil, err
}
@ -324,7 +381,7 @@ func (m *Manager) createNewDeposit(ctx context.Context,
state: Deposited,
OutPoint: utxo.OutPoint,
Value: utxo.Value,
ConfirmationHeight: int64(blockHeight),
ConfirmationHeight: confirmationHeight,
TimeOutSweepPkScript: timeoutSweepPkScript,
}
@ -340,37 +397,167 @@ func (m *Manager) createNewDeposit(ctx context.Context,
return deposit, nil
}
// getBlockHeight retrieves the block height of a given utxo.
func (m *Manager) getBlockHeight(ctx context.Context,
utxo *lnwallet.Utxo) (uint32, error) {
// confirmationHeightForUtxo derives the first confirmation height of a wallet
// UTXO from the manager's current block height. Unconfirmed UTXOs return 0.
func confirmationHeightForUtxo(currentHeight uint32,
utxo *lnwallet.Utxo) (int64, error) {
addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters(
ctx,
)
if err != nil {
return 0, fmt.Errorf("couldn't get confirmation height for "+
"deposit, %w", err)
if utxo.Confirmations <= 0 {
return 0, nil
}
notifChan, errChan, err :=
m.cfg.ChainNotifier.RegisterConfirmationsNtfn(
ctx, &utxo.OutPoint.Hash, addressParams.PkScript,
MinConfs, addressParams.InitiationHeight,
if currentHeight == 0 {
return 0, errors.New("current block height unavailable")
}
firstConfirmationHeight := int64(currentHeight) - utxo.Confirmations + 1
if firstConfirmationHeight <= 0 {
return 0, fmt.Errorf("invalid confirmation height %d for %v "+
"with current height %d and %d confirmations",
firstConfirmationHeight, utxo.OutPoint, currentHeight,
utxo.Confirmations)
}
return firstConfirmationHeight, nil
}
// updateDepositConfirmations syncs first confirmation heights for deposits that
// are visible in lnd's wallet view.
func (m *Manager) updateDepositConfirmations(ctx context.Context,
utxos []*lnwallet.Utxo, currentHeight uint32) error {
for _, utxo := range utxos {
m.mu.Lock()
deposit, ok := m.deposits[utxo.OutPoint]
m.mu.Unlock()
if !ok {
continue
}
err := func() error {
deposit.Lock()
defer deposit.Unlock()
previousConfirmationHeight := deposit.ConfirmationHeight
confirmationHeight, err := confirmationHeightForUtxo(
currentHeight, utxo,
)
if err != nil {
return err
}
if deposit.ConfirmationHeight == confirmationHeight {
return nil
}
deposit.ConfirmationHeight = confirmationHeight
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
if err != nil {
deposit.ConfirmationHeight = previousConfirmationHeight
return err
}
return nil
}()
if err != nil {
return err
}
}
return nil
}
// syncActiveDeposits reconciles the live active set with lnd's current wallet
// view. Known Deposited records that are visible but inactive become active
// again, and active Deposited records that are no longer wallet-visible are
// removed from the live set. The DB record is left untouched as historical
// evidence that the outpoint was once detected.
func (m *Manager) syncActiveDeposits(ctx context.Context,
utxos []*lnwallet.Utxo) error {
currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos))
for _, utxo := range utxos {
currentUtxos[utxo.OutPoint] = struct{}{}
}
type deactivatedDeposit struct {
outpoint wire.OutPoint
fsm *FSM
}
toActivate := make([]*Deposit, 0, len(utxos))
var toDeactivate []deactivatedDeposit
func() {
m.mu.Lock()
defer m.mu.Unlock()
toDeactivate = make(
[]deactivatedDeposit, 0, len(m.activeDeposits),
)
if err != nil {
return 0, err
for _, utxo := range utxos {
deposit, ok := m.deposits[utxo.OutPoint]
if !ok {
continue
}
if _, active := m.activeDeposits[utxo.OutPoint]; active {
continue
}
if !deposit.IsInState(Deposited) {
continue
}
toActivate = append(toActivate, deposit)
}
for outpoint, fsm := range m.activeDeposits {
if _, ok := currentUtxos[outpoint]; ok {
continue
}
if fsm == nil || fsm.deposit == nil {
continue
}
if !fsm.deposit.IsInState(Deposited) {
continue
}
delete(m.activeDeposits, outpoint)
toDeactivate = append(toDeactivate, deactivatedDeposit{
outpoint: outpoint,
fsm: fsm,
})
}
}()
for _, deactivated := range toDeactivate {
deactivated.fsm.Stop()
log.Infof("Removed vanished deposit %v from active set",
deactivated.outpoint)
}
select {
case tx := <-notifChan:
return tx.BlockHeight, nil
for _, deposit := range toActivate {
if !deposit.IsInState(Deposited) {
continue
}
case err := <-errChan:
return 0, err
err := m.startDepositFsm(ctx, deposit)
if err != nil {
m.removeActiveDeposit(deposit.OutPoint)
case <-ctx.Done():
return 0, ctx.Err()
return err
}
log.Infof("Reactivated visible deposit %v", deposit.OutPoint)
}
return nil
}
// filterNewDeposits filters the given utxos for new deposits that we haven't
@ -604,11 +791,41 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) {
}
}
// GetAllDeposits returns all active deposits.
// GetAllDeposits returns all known deposits from the database.
func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) {
return m.cfg.Store.AllDeposits(ctx)
}
// GetVisibleDeposits returns deposits that should be exposed through normal
// user-facing views. The database can contain historical Deposited rows whose
// outpoints are no longer present in lnd's current wallet view, for example
// after replacement or reorg. Once the manager has recovered its live cache,
// plain Deposited records are only visible while their outpoint is in the
// active set.
func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) {
deposits, err := m.cfg.Store.AllDeposits(ctx)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
liveCacheReady := len(m.deposits) > 0
filtered := make([]*Deposit, 0, len(deposits))
for _, d := range deposits {
if liveCacheReady && d.IsInState(Deposited) {
if _, ok := m.activeDeposits[d.OutPoint]; !ok {
continue
}
}
filtered = append(filtered, d)
}
return filtered, nil
}
// UpdateDeposit overrides all fields of the deposit with given ID in the store.
func (m *Manager) UpdateDeposit(ctx context.Context, d *Deposit) error {
d.Lock()

View file

@ -0,0 +1,39 @@
package deposit
import (
"testing"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)
// TestConfirmationHeightForUtxo verifies confirmation heights are derived from
// the current block height and wallet confirmation count.
func TestConfirmationHeightForUtxo(t *testing.T) {
t.Run("unconfirmed", func(t *testing.T) {
height, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{})
require.NoError(t, err)
require.Zero(t, height)
})
t.Run("confirmed", func(t *testing.T) {
height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 2,
},
Confirmations: 6,
})
require.NoError(t, err)
require.EqualValues(t, 96, height)
})
t.Run("invalid current height", func(t *testing.T) {
_, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{
Confirmations: 6,
})
require.ErrorContains(t, err, "invalid confirmation height")
})
}

View file

@ -1,14 +1,344 @@
package deposit
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightninglabs/loop/staticaddr/version"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// TestReconcileDepositsSerialized verifies reconciliation is serialized across
// concurrent callers.
func TestReconcileDepositsSerialized(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 0,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
mockStore := new(mockStore)
var createCalls atomic.Int32
createEntered := make(chan struct{})
releaseCreate := make(chan struct{})
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(mock.Arguments) {
if createCalls.Add(1) == 1 {
close(createEntered)
}
<-releaseCreate
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
var wg sync.WaitGroup
wg.Add(2)
errs := make(chan error, 2)
go func() {
defer wg.Done()
errs <- manager.reconcileDeposits(ctx)
}()
<-createEntered
go func() {
defer wg.Done()
errs <- manager.reconcileDeposits(ctx)
}()
time.Sleep(100 * time.Millisecond)
close(releaseCreate)
wg.Wait()
close(errs)
var gotErrs []error
for err := range errs {
gotErrs = append(gotErrs, err)
}
require.EqualValues(t, 1, createCalls.Load())
require.Len(t, manager.deposits, 1)
require.Empty(t, manager.activeDeposits)
require.Len(t, gotErrs, 2)
var errCount int
for _, err := range gotErrs {
if err == nil {
continue
}
errCount++
errMsg := err.Error()
require.True(
t,
strings.Contains(
errMsg, "unable to start new deposit FSM",
) || strings.Contains(
errMsg, "unable to sync active deposits",
),
"unexpected error: %v", err,
)
}
require.Equal(t, 2, errCount)
}
// TestReconcileConfirmedDepositUsesCurrentHeight verifies confirmation heights
// are derived from the manager's current block height.
func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
utxo := &lnwallet.Utxo{
AddressType: lnwallet.TaprootPubkey,
Value: btcutil.Amount(100_000),
Confirmations: 3,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{8},
Index: 1,
},
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
mockStore := new(mockStore)
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit := args.Get(1).(*Deposit)
require.EqualValues(t, 98, createdDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
manager.currentHeight.Store(100)
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to start new deposit FSM")
}
// TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit
// which remains wallet-visible but loses confirmations has its confirmation
// height reset. This can happen if a confirmed transaction is reorged back into
// the mempool.
func TestUpdateDepositConfirmationsResetsReorgedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{7},
Index: 2,
}
deposit := &Deposit{
OutPoint: outpoint,
ConfirmationHeight: 99,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Confirmations: 0,
}
mockStore := new(mockStore)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
require.Zero(t, updatedDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
Store: mockStore,
})
manager.deposits[outpoint] = deposit
err := manager.updateDepositConfirmations(ctx, []*lnwallet.Utxo{utxo}, 0)
require.NoError(t, err)
require.Zero(t, deposit.ConfirmationHeight)
mockStore.AssertExpectations(t)
}
// TestUpdateDepositConfirmationsRecomputesPositiveHeight verifies that a
// deposit which is confirmed again at a different height after a reorg does
// not retain its stale, positive confirmation height.
func TestUpdateDepositConfirmationsRecomputesPositiveHeight(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{10},
Index: 3,
}
deposit := &Deposit{
OutPoint: outpoint,
ConfirmationHeight: 100,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Confirmations: 2,
}
mockStore := new(mockStore)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
require.EqualValues(t, 109, updatedDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
Store: mockStore,
})
manager.deposits[outpoint] = deposit
err := manager.updateDepositConfirmations(
ctx, []*lnwallet.Utxo{utxo}, 110,
)
require.NoError(t, err)
require.EqualValues(t, 109, deposit.ConfirmationHeight)
mockStore.AssertExpectations(t)
}
// TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit verifies that a
// missing wallet outpoint is removed from the live active set without mutating
// its historical DB state.
func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) {
ctx := t.Context()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 7,
}
deposit := &Deposit{
OutPoint: outpoint,
}
deposit.SetState(Deposited)
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{}, nil)
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
deposit: deposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-fsm.stopChan
close(fsm.quitChan)
}()
manager.activeDeposits[outpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after deposit vanished")
}
}
// TestReconcileDepositsDeactivatesVanishedConfirmedDeposit verifies that a
// previously confirmed deposit is also removed from the live active set if it
// vanishes from the wallet view.
func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{9},
Index: 4,
}
deposit := &Deposit{
OutPoint: outpoint,
ConfirmationHeight: 123,
}
deposit.SetState(Deposited)
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{}, nil)
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
deposit: deposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-fsm.stopChan
close(fsm.quitChan)
}()
manager.activeDeposits[outpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.EqualValues(t, 123, deposit.ConfirmationHeight)
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after confirmed deposit vanished")
}
}
// TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a
// duplicated selection is rejected before the manager tries to lock the same
// deposit twice.
@ -126,3 +456,280 @@ func TestLockDepositsAllowsReversedConcurrentRequests(t *testing.T) {
}
}
}
// TestReconcileDepositsReactivatesReappearedDeposit verifies that the same
// outpoint can become active again if lnd reports it after a prior wallet-view
// miss.
func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 5,
}
deposit := &Deposit{
OutPoint: outpoint,
Value: btcutil.Amount(100_000),
ConfirmationHeight: 77,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
}, nil)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return((*script.StaticAddress)(nil), nil)
mockStore := new(mockStore)
var updateStates []fsm.StateType
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
updateStates = append(updateStates, updatedDeposit.state)
if updatedDeposit.isInStateNoLock(Deposited) {
require.Zero(t, updatedDeposit.ConfirmationHeight)
}
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
// Reconciliation should reactivate the existing record instead of
// creating a second deposit entry for the same outpoint.
require.NoError(t, manager.reconcileDeposits(ctx))
require.Equal(t, Deposited, deposit.GetState())
require.Zero(t, deposit.ConfirmationHeight)
require.Len(t, manager.activeDeposits, 1)
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
}
// TestReconcileDepositsKeepsInactiveOnFSMStartFailure verifies that a failed
// reactivation does not leave memory saying a deposit is active without an FSM.
func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) {
ctx := context.Background()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{11},
Index: 5,
}
deposit := &Deposit{
OutPoint: outpoint,
Value: btcutil.Amount(100_000),
ConfirmationHeight: 77,
}
deposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: outpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
var (
updateStates []fsm.StateType
updateHeights []int64
)
mockStore := new(mockStore)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
updateStates = append(updateStates, updatedDeposit.state)
updateHeights = append(
updateHeights, updatedDeposit.ConfirmationHeight,
)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to sync active deposits")
require.Equal(t, Deposited, deposit.GetState())
require.Zero(t, deposit.ConfirmationHeight)
require.Empty(t, manager.activeDeposits)
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
require.EqualValues(t, []int64{0}, updateHeights)
}
// TestReconcileDepositsDeactivatesBeforeActivationFailure verifies that a
// failed reactivation of one visible deposit does not leave another vanished
// deposit in the live active set.
func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) {
ctx := context.Background()
visibleOutpoint := wire.OutPoint{
Hash: chainhash.Hash{21},
Index: 5,
}
vanishedOutpoint := wire.OutPoint{
Hash: chainhash.Hash{22},
Index: 6,
}
visibleDeposit := &Deposit{
OutPoint: visibleOutpoint,
Value: btcutil.Amount(100_000),
}
visibleDeposit.SetState(Deposited)
vanishedDeposit := &Deposit{
OutPoint: vanishedOutpoint,
Value: btcutil.Amount(100_000),
}
vanishedDeposit.SetState(Deposited)
utxo := &lnwallet.Utxo{
OutPoint: visibleOutpoint,
Value: visibleDeposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: new(mockStore),
})
manager.deposits[visibleOutpoint] = visibleDeposit
manager.deposits[vanishedOutpoint] = vanishedDeposit
vanishedFsm := &FSM{
deposit: vanishedDeposit,
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-vanishedFsm.stopChan
close(vanishedFsm.quitChan)
}()
manager.activeDeposits[vanishedOutpoint] = vanishedFsm
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to sync active deposits")
require.Empty(t, manager.activeDeposits)
select {
case <-vanishedFsm.quitChan:
case <-time.After(time.Second):
t.Fatal("vanished deposit fsm did not stop")
}
}
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
// the outpoint selected by a loop-in.
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
ctx := context.Background()
mockLnd := test.NewMockLnd()
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 8,
}
newOutpoint := wire.OutPoint{
Hash: chainhash.Hash{5},
Index: 9,
}
depositID, err := GetRandomDepositID()
require.NoError(t, err)
deposit := &Deposit{
ID: depositID,
OutPoint: oldOutpoint,
Value: btcutil.Amount(100_000),
}
deposit.SetState(LoopingIn)
utxo := &lnwallet.Utxo{
OutPoint: newOutpoint,
Value: deposit.Value,
Confirmations: 0,
}
mockAddressManager := new(mockAddressManager)
mockAddressManager.On(
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
).Return([]*lnwallet.Utxo{utxo}, nil)
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
ProtocolVersion: version.ProtocolVersion_V0,
}, nil)
mockAddressManager.On(
"GetStaticAddress", mock.Anything,
).Return((*script.StaticAddress)(nil), nil)
mockStore := new(mockStore)
var createdDeposit *Deposit
mockStore.On(
"CreateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
createdDeposit = args.Get(1).(*Deposit)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
manager.deposits[oldOutpoint] = deposit
fsm := &FSM{}
manager.activeDeposits[oldOutpoint] = fsm
require.NoError(t, manager.reconcileDeposits(ctx))
require.Same(t, deposit, manager.deposits[oldOutpoint])
require.Equal(t, oldOutpoint, deposit.OutPoint)
require.Equal(t, LoopingIn, deposit.GetState())
replacement, ok := manager.deposits[newOutpoint]
require.True(t, ok)
require.Same(t, createdDeposit, replacement)
require.NotEqual(t, depositID, replacement.ID)
require.Equal(t, newOutpoint, replacement.OutPoint)
require.Equal(t, Deposited, replacement.GetState())
require.Zero(t, replacement.ConfirmationHeight)
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
mockStore.AssertNotCalled(
t, "UpdateDeposit", mock.Anything, mock.Anything,
)
}

View file

@ -3,6 +3,7 @@ package deposit
import (
"context"
"encoding/hex"
"errors"
"testing"
"time"
@ -133,11 +134,30 @@ func (m *mockAddressManager) ListUnspent(ctx context.Context,
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
args := m.Called(ctx, minConfs, maxConfs)
if listUnspent, ok := args.Get(0).(func() []*lnwallet.Utxo); ok {
return listUnspent(), args.Error(1)
}
return args.Get(0).([]*lnwallet.Utxo),
args.Error(1)
}
// listUnspentOverride delegates all address-manager methods except
// ListUnspent to another implementation.
type listUnspentOverride struct {
AddressManager
listUnspent func(context.Context, int32, int32) ([]*lnwallet.Utxo,
error)
}
// ListUnspent calls the override's ListUnspent implementation.
func (l *listUnspentOverride) ListUnspent(ctx context.Context,
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
return l.listUnspent(ctx, minConfs, maxConfs)
}
func (m *mockAddressManager) GetTaprootAddress(clientPubkey,
serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot,
error) {
@ -237,6 +257,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:
@ -307,6 +331,156 @@ func TestManager(t *testing.T) {
}
}
// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial
// block epoch consumed during startup is delivered to recovered deposit FSMs.
func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
const defaultTimeout = 30 * time.Second
testContext := newManagerTestContext(t)
initChan := make(chan struct{})
runErrChan := make(chan error, 1)
go func() {
runErrChan <- testContext.manager.Run(ctx, initChan)
}()
// Send only the startup block at the recovered deposit's expiry height.
testContext.blockChan <- int32(
defaultDepositConfirmations + defaultExpiry,
)
select {
case <-initChan:
case err := <-runErrChan:
require.NoError(t, err, "manager failed to start")
case <-time.After(defaultTimeout):
t.Fatal("manager timed out starting")
}
select {
case <-testContext.mockLnd.SignOutputRawChannel:
case <-time.After(defaultTimeout):
t.Fatal("did not receive sign request")
}
select {
case <-testContext.mockLnd.TxPublishChannel:
case <-time.After(defaultTimeout):
t.Fatal("did not receive published expiry tx")
}
cancel()
select {
case err := <-runErrChan:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(defaultTimeout):
t.Fatal("manager did not stop")
}
}
// TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit
// FSMs cannot make an expiry decision from stale confirmation data when wallet
// reconciliation fails at startup or while processing a later block.
func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) {
testCases := []struct {
name string
startupHeight int32
blockHeight int32
}{
{
name: "startup",
startupHeight: int32(
defaultDepositConfirmations + defaultExpiry,
),
},
{
name: "block",
startupHeight: int32(defaultDepositConfirmations),
blockHeight: int32(
defaultDepositConfirmations + defaultExpiry,
),
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
testContext := newManagerTestContext(t)
baseAddressManager := testContext.mockAddressManager
var listUnspentCalls int
testContext.manager.cfg.AddressManager =
&listUnspentOverride{
AddressManager: baseAddressManager,
listUnspent: func(ctx context.Context,
minConfs, maxConfs int32) (
[]*lnwallet.Utxo, error) {
listUnspentCalls++
if testCase.blockHeight != 0 &&
listUnspentCalls == 1 {
return baseAddressManager.ListUnspent(
ctx, minConfs, maxConfs,
)
}
return nil, errors.New(
"injected reconciliation failure",
)
},
}
initChan := make(chan struct{})
runErrChan := make(chan error, 1)
go func() {
runErrChan <- testContext.manager.Run(ctx, initChan)
}()
testContext.blockChan <- testCase.startupHeight
select {
case <-initChan:
case err := <-runErrChan:
require.NoError(t, err, "manager failed to start")
case <-time.After(time.Second):
t.Fatal("manager timed out starting")
}
if testCase.blockHeight != 0 {
testContext.blockChan <- testCase.blockHeight
}
select {
case <-testContext.mockLnd.SignOutputRawChannel:
t.Fatal("expiry sweep signed with stale deposit data")
case <-time.After(200 * time.Millisecond):
}
cancel()
select {
case err := <-runErrChan:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(time.Second):
t.Fatal("manager did not stop")
}
})
}
}
// ManagerTestContext is a helper struct that contains all the necessary
// components to test the reservation manager.
type ManagerTestContext struct {
@ -366,6 +540,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil)
var manager *Manager
mockAddressManager.On(
"GetStaticAddressParameters", mock.Anything,
).Return(&script.Parameters{
@ -374,7 +549,19 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
mockAddressManager.On(
"ListUnspent", mock.Anything, mock.Anything, mock.Anything,
).Return([]*lnwallet.Utxo{utxo}, nil)
).Return(func() []*lnwallet.Utxo {
currentUtxo := *utxo
currentHeight := manager.currentHeight.Load()
if currentHeight < defaultDepositConfirmations {
currentUtxo.Confirmations = 0
} else {
currentUtxo.Confirmations = int64(
currentHeight - defaultDepositConfirmations + 1,
)
}
return []*lnwallet.Utxo{&currentUtxo}
}, nil)
// Define the expected return values for the mocks.
mockChainNotifier.On(
@ -394,7 +581,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
Signer: mockLnd.Signer,
}
manager := NewManager(cfg)
manager = NewManager(cfg)
testContext := &ManagerTestContext{
manager: manager,

View file

@ -39,6 +39,8 @@ const (
DefaultPaymentTimeoutSeconds = 60
defaultInvoiceCleanupTimeout = 5 * time.Second
monitorRetryDelay = time.Second
)
var (
@ -69,7 +71,10 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
return
}
f.cancelSwapInvoice()
if err := f.cancelSwapInvoice(); err != nil {
f.Warnf("unable to clean up invoice for swap %v: %v",
f.loopIn.SwapHash, err)
}
}()
returnError := func(err error) fsm.EventType {
@ -341,11 +346,12 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
return event
}
// cancelSwapInvoice best-effort cancels the current swap invoice using a
// detached timeout-limited context.
func (f *FSM) cancelSwapInvoice() {
if f.loopIn.SwapInvoice == "" {
return
// cancelSwapInvoice cancels the current swap invoice using a detached,
// timeout-limited context. Callers that must not proceed while the invoice may
// still be payable can use the returned error to retry.
func (f *FSM) cancelSwapInvoice() error {
if f.loopIn.SwapHash == (lntypes.Hash{}) {
return nil
}
cleanupCtx, cancel := context.WithTimeout(
@ -354,10 +360,7 @@ func (f *FSM) cancelSwapInvoice() {
defer cancel()
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
if err != nil {
f.Warnf("unable to cancel invoice for swap %v: %v",
f.loopIn.SwapHash, err)
}
return err
}
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
@ -383,12 +386,167 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
return fsm.NoOp, false
default:
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
"canceled", update.State, f.loopIn.SwapHash)
return f.HandleError(err), true
// An unknown state is not evidence that the invoice can no longer
// settle. Keep monitoring rather than leaving the deposits available
// for reuse.
f.Warnf("unexpected invoice state %v for swap hash %v",
update.State, f.loopIn.SwapHash)
return fsm.NoOp, false
}
}
// selectedDepositConfirmationHeights returns current confirmation heights for
// the original deposit outpoints selected by this loop-in.
func selectedDepositConfirmationHeights(
loopIn *StaticAddressLoopIn) map[string]int64 {
confirmations := make(map[string]int64, len(loopIn.Deposits))
outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints))
for _, outpoint := range loopIn.DepositOutpoints {
outpoints[outpoint] = struct{}{}
}
for _, d := range loopIn.Deposits {
if d == nil {
continue
}
outpoint := d.OutPoint.String()
confirmationHeight := d.GetConfirmationHeight()
if _, ok := outpoints[outpoint]; !ok {
continue
}
confirmations[outpoint] = confirmationHeight
}
return confirmations
}
// refreshSelectedDeposits reloads the loop-in's selected deposits from the
// deposit manager/store so recovery does not rely on stale deposit snapshots.
func (f *FSM) refreshSelectedDeposits(ctx context.Context) error {
if f.cfg.DepositManager == nil || len(f.loopIn.DepositOutpoints) == 0 {
return nil
}
err := f.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return fmt.Errorf("unable to refresh deposit wallet view: %w", err)
}
const ignoreUnknownOutpoints = false
deposits, err := f.cfg.DepositManager.DepositsForOutpoints(
ctx, f.loopIn.DepositOutpoints, ignoreUnknownOutpoints,
)
if err != nil {
return err
}
if len(deposits) != len(f.loopIn.DepositOutpoints) {
return fmt.Errorf("expected %d selected deposits, got %d",
len(f.loopIn.DepositOutpoints), len(deposits))
}
f.loopIn.Deposits = deposits
return nil
}
// legacyMinConfsReached returns true once every original deposit is confirmed
// and the youngest original deposit has reached the legacy confirmation target.
func legacyMinConfsReached(outpoints []string,
confirmationHeights map[string]int64, currentHeight int32) bool {
if currentHeight <= 0 || len(outpoints) == 0 {
return false
}
youngestConfirmation := int64(0)
for _, outpoint := range outpoints {
confirmationHeight, ok := confirmationHeights[outpoint]
if !ok || confirmationHeight <= 0 {
return false
}
if confirmationHeight > youngestConfirmation {
youngestConfirmation = confirmationHeight
}
}
return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1
}
// shouldStartLegacyConfirmationFallback reports whether the local MinConfs
// payment deadline fallback should be armed at the current block height.
//
// The primary path starts the deadline from a server risk-accepted notification.
// This fallback preserves the legacy client-side MinConfs behavior when no risk
// decision has been observed locally: once every original deposit reaches
// MinConfs, the client treats that as enough confirmation-risk clearance to
// start the payment window. The selected deposits are refreshed first so
// recovered swaps do not depend on stale in-memory deposit snapshots.
func (f *FSM) shouldStartLegacyConfirmationFallback(ctx context.Context,
currentHeight int32) bool {
err := f.refreshSelectedDeposits(ctx)
if err != nil {
f.Warnf("unable to refresh selected deposits for legacy "+
"confirmation fallback: %v", err)
return false
}
depositConfirmationHeights := selectedDepositConfirmationHeights(
f.loopIn,
)
return legacyMinConfsReached(
f.loopIn.DepositOutpoints, depositConfirmationHeights,
currentHeight,
)
}
// originalDepositOutpointUnavailable checks the original selected deposit
// outpoints against the chain backend's UTXO view.
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
bool, error) {
if f.cfg.TxOutChecker == nil {
return false, nil
}
if len(f.loopIn.DepositOutpoints) == 0 {
return false, nil
}
outpoints := make([]wire.OutPoint, len(f.loopIn.DepositOutpoints))
for i, outpointStr := range f.loopIn.DepositOutpoints {
outpoint, err := wire.NewOutPointFromString(outpointStr)
if err != nil {
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
outpointStr, err)
}
outpoints[i] = *outpoint
}
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
if err != nil {
return false, fmt.Errorf("unable to get txouts: %w", err)
}
for _, outpoint := range outpoints {
if txOuts[outpoint] == nil {
return true, nil
}
}
return false, nil
}
// SignHtlcTxAction is called if the htlc was initialized and the server
// provided the necessary information to construct the htlc tx. We sign the htlc
// tx and send the signatures to the server.
@ -397,6 +555,21 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
var err error
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
if err != nil {
return f.HandleError(err)
}
if outpointUnavailable {
err = errors.New("original deposit outpoint no longer available")
f.Warnf("%v, canceling swap invoice", err)
if cancelErr := f.cancelSwapInvoice(); cancelErr != nil {
f.Warnf("unable to cancel invoice for swap %v: %v",
f.loopIn.SwapHash, cancelErr)
}
return f.HandleError(err)
}
f.loopIn.AddressParams, err =
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
@ -618,6 +791,29 @@ func (f *FSM) cleanUpSessions(ctx context.Context,
func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
retryMonitor := func(err error) fsm.EventType {
if ctx.Err() != nil {
return fsm.NoOp
}
f.Errorf("monitoring failed: %v, retrying", err)
invoice, lookupErr := f.cfg.LndClient.LookupInvoice(
ctx, f.loopIn.SwapHash,
)
if lookupErr == nil && invoice.State == invoices.ContractSettled {
return OnPaymentReceived
}
select {
case <-time.After(monitorRetryDelay):
return OnRecover
case <-ctx.Done():
return fsm.NoOp
}
}
// Subscribe to the state of the swap invoice. If upon restart recovery,
// we land here and observe that the invoice is already canceled, it can
// only be the case where a user-provided payment timeout was hit, the
@ -640,18 +836,20 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
err = fmt.Errorf("unable to subscribe to swap "+
"invoice: %w", err)
return f.HandleError(err)
return retryMonitor(err)
}
htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams)
if err != nil {
err = fmt.Errorf("unable to get htlc: %w", err)
return f.HandleError(err)
return retryMonitor(err)
}
// Subscribe to htlc tx confirmation.
reorgChan := make(chan struct{}, 1)
// registerHtlcConf registers for the HTLC transaction confirmation using
// the current reorg channel.
registerHtlcConf := func() (chan *chainntnfs.TxConfirmation, chan error,
error) {
@ -671,7 +869,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
err = fmt.Errorf("unable to monitor htlc tx confirmation: %w",
err)
return f.HandleError(err)
return retryMonitor(err)
}
// Subscribe to new blocks.
@ -684,9 +882,18 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
err = fmt.Errorf("unable to subscribe to new blocks: %w", err)
return f.HandleError(err)
return retryMonitor(err)
}
// The watcher keeps notification normalization and timestamp restoration
// outside of the swap-state handling below.
riskWatcher := newConfirmationRiskWatcher(
f.cfg, f.loopIn.SwapHash, f.Warnf,
)
riskUpdateChan, cancelRiskNotificationSubscriptions :=
riskWatcher.subscribe(ctx)
defer cancelRiskNotificationSubscriptions()
// Look up the current invoice state after registering subscriptions so
// recovery can resume the payment deadline from the latest known state.
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
@ -695,51 +902,233 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return fsm.NoOp
}
err = fmt.Errorf("unable to look up invoice by swap hash: %w",
err)
return f.HandleError(err)
// A failed lookup leaves the invoice state unknown. The active
// subscription can still provide an authoritative update, so keep
// monitoring and, most importantly, keep the deposits locked.
f.Warnf("unable to look up invoice by swap hash: %v", err)
invoice = &lndclient.Invoice{}
}
// Create the swap payment timeout timer. If it runs out we cancel the
// invoice, but keep monitoring the htlc confirmation.
// If the invoice was canceled, e.g. before a restart, we don't need to
// set a new deadline.
var deadlineChan <-chan time.Time
if invoice.State != invoices.ContractCanceled {
// If the invoice is still live we set the timeout to the
// remaining payment time. If too much time has elapsed, e.g.
// after a restart, we cancel the invoice immediately and keep
// monitoring the HTLC until it can no longer confirm.
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
// A settled invoice always takes precedence over a recovered risk
// rejection or an elapsed payment deadline.
if invoice.State == invoices.ContractSettled {
return OnPaymentReceived
}
// If the invoice isn't cancelled yet and the payment timeout
// elapsed, we set the timeout to 0 to cancel the invoice and
// unlock the deposits immediately. Otherwise, we start the
// timer with the remaining seconds to timeout.
timeout := time.Duration(0) * time.Second
if remainingTimeSeconds > 0 {
timeout = time.Duration(remainingTimeSeconds) *
time.Second
}
deadlineChan = time.NewTimer(timeout).C
} else {
invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled
if invoiceCanceledForNonPayment {
// If the invoice was canceled previously we end our
// subscription to invoice updates.
cancelInvoiceSubscription()
}
cancelInvoice := func() {
f.Errorf("timeout waiting for invoice to be " +
"paid, canceling invoice")
// Create the swap payment timeout timer after the server confirms
// confirmation risk was accepted. If a server does not support risk
// notifications, fall back after the legacy deposit confirmation depth.
var (
deadlineChan <-chan time.Time
deadlineTimer *time.Timer
deadlineStarted bool
)
// Stop the payment deadline timer when leaving the monitor action.
defer func() {
if deadlineTimer != nil {
deadlineTimer.Stop()
}
}()
// depositsInState reports whether all selected deposits are currently
// in the requested state.
depositsInState := func(state fsm.StateType) bool {
if len(f.loopIn.Deposits) == 0 {
return false
}
for _, d := range f.loopIn.Deposits {
if d == nil {
return false
}
if !d.IsInState(state) {
return false
}
}
return true
}
// startPaymentDeadline arms the server payment timeout from the decision
// time when one is available.
startPaymentDeadline := func(reason string, startedAt time.Time) {
if deadlineStarted || invoice.State == invoices.ContractCanceled {
return
}
timeout := f.loopIn.PaymentTimeoutDuration()
if !startedAt.IsZero() {
timeout -= time.Since(startedAt)
if timeout < 0 {
timeout = 0
}
}
f.Infof("starting payment deadline after %s", reason)
deadlineTimer = time.NewTimer(timeout)
deadlineChan = deadlineTimer.C
deadlineStarted = true
}
depositsLockedForHtlcTimeout := depositsInState(
deposit.SweepHtlcTimeout,
)
// transitionDepositsToHtlcTimeout locks deposits into timeout sweeping once
// the HTLC is confirmed and the invoice cannot be paid.
transitionDepositsToHtlcTimeout := func(reason string) error {
if depositsLockedForHtlcTimeout ||
depositsInState(deposit.SweepHtlcTimeout) {
depositsLockedForHtlcTimeout = true
return nil
}
depositsToTransition := make(
[]*deposit.Deposit, 0, len(f.loopIn.Deposits),
)
for _, d := range f.loopIn.Deposits {
if d != nil && d.IsInState(deposit.SweepHtlcTimeout) {
continue
}
depositsToTransition = append(depositsToTransition, d)
}
transitionErr := f.cfg.DepositManager.TransitionDeposits(
ctx, depositsToTransition,
deposit.OnSweepingHtlcTimeout,
deposit.SweepHtlcTimeout,
)
if transitionErr != nil {
// WaitForState can report cancellation after the deposit FSMs
// already reached the target state. Do not turn that shutdown
// error into success: the monitor must return NoOp and remain
// recoverable instead of advancing the loop-in FSM.
if ctx.Err() != nil {
return ctx.Err()
}
// The transition can return an error after every deposit
// already reached the target state. Treat that as
// success, but never advance with a partial transition.
if depositsInState(deposit.SweepHtlcTimeout) {
depositsLockedForHtlcTimeout = true
return nil
}
return fmt.Errorf("unable to transition deposits to the htlc "+
"timeout sweeping state after %s: %w",
reason, transitionErr)
}
if !depositsInState(deposit.SweepHtlcTimeout) {
return fmt.Errorf("not all deposits reached the htlc timeout "+
"sweeping state after %s", reason)
}
depositsLockedForHtlcTimeout = true
return nil
}
// startLegacyFallback starts the payment deadline once the old local
// minimum-confirmation rule has been satisfied.
startLegacyFallback := func(reason string, currentHeight int32) {
if deadlineStarted || invoice.State == invoices.ContractCanceled ||
f.loopIn.ConfirmationRiskDecision !=
ConfirmationRiskDecisionNone {
return
}
if f.shouldStartLegacyConfirmationFallback(ctx, currentHeight) {
decisionTime, ok := riskWatcher.durableDecisionTime(
ctx, ConfirmationRiskDecisionAccepted,
)
if !ok {
return
}
f.loopIn.ConfirmationRiskDecision =
ConfirmationRiskDecisionAccepted
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
startPaymentDeadline(reason, decisionTime)
}
}
// cancelInvoice only marks the invoice canceled after lnd acknowledges
// the request or the lookup/subscription already observed that state.
// Failures recover the monitor state without releasing deposits.
cancelInvoice := func(reason string) (fsm.EventType, bool) {
if invoice.State != invoices.ContractCanceled {
f.Errorf("%s, canceling invoice", reason)
if err := f.cancelSwapInvoice(); err != nil {
return retryMonitor(err), false
}
}
// Cancel the lndclient invoice subscription.
cancelInvoiceSubscription()
invoice.State = invoices.ContractCanceled
invoiceCanceledForNonPayment = true
// Reuse the same helper as InitHtlcAction so timeout cleanup
// follows the same detached-context path as early-init cleanup.
f.cancelSwapInvoice()
return fsm.NoOp, true
}
// handleRiskRejected records a server rejection and only exits through
// the generic error path once the invoice can no longer settle.
handleRiskRejected := func(reason string,
decisionTime time.Time) fsm.EventType {
f.loopIn.ConfirmationRiskDecision =
ConfirmationRiskDecisionRejected
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
event, canceled := cancelInvoice(
"server rejected confirmation risk wait after " + reason,
)
if !canceled {
return event
}
return f.HandleError(fmt.Errorf(
"server rejected confirmation risk wait after %s", reason,
))
}
switch f.loopIn.ConfirmationRiskDecision {
case ConfirmationRiskDecisionAccepted:
startPaymentDeadline(
"recovered risk accepted notification",
f.loopIn.ConfirmationRiskDecisionTime,
)
case ConfirmationRiskDecisionRejected:
decisionTime := riskWatcher.decisionTime(
ctx, ConfirmationRiskDecisionRejected,
)
return handleRiskRejected(
"recovered risk rejection", decisionTime,
)
}
info, err := f.cfg.LndClient.GetInfo(ctx)
if err != nil {
f.Warnf("unable to query current height for legacy confirmation "+
"fallback: %v", err)
} else {
startLegacyFallback(
"legacy confirmation fallback", int32(info.BlockHeight),
)
}
htlcConfirmed := false
@ -749,6 +1138,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
f.Infof("htlc tx confirmed")
htlcConfirmed = true
if invoiceCanceledForNonPayment {
err = transitionDepositsToHtlcTimeout(
"htlc confirmation after invoice cancellation",
)
if err != nil {
return retryMonitor(err)
}
}
case err = <-htlcErrConfChan:
if ctx.Err() != nil {
@ -772,7 +1169,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
err = fmt.Errorf("unable to re-register for "+
"htlc tx confirmation: %w", err)
return f.HandleError(err)
return retryMonitor(err)
}
case <-reorgChan:
@ -790,23 +1187,68 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
err = fmt.Errorf("unable to monitor htlc tx "+
"confirmation: %v", err)
return f.HandleError(err)
return retryMonitor(err)
}
case <-deadlineChan:
// If the server didn't pay the invoice on time, we
// cancel the invoice and keep monitoring the htlc tx
// confirmation. We also need to unlock the deposits to
// re-enable them for loop-ins and withdrawals.
cancelInvoice()
deadlineChan = nil
// If the server didn't pay the invoice on time, we cancel
// it and keep monitoring the htlc tx. Confirmed HTLC
// deposits remain locked for timeout sweeping.
event, canceled := cancelInvoice(
"timeout waiting for invoice to be paid",
)
if !canceled {
return event
}
if htlcConfirmed {
err = transitionDepositsToHtlcTimeout(
"payment deadline",
)
if err != nil {
return retryMonitor(err)
}
continue
}
err = f.unlockDeposits(ctx)
if err != nil {
f.Errorf("unable to unlock deposits after "+
"payment deadline: %v", err)
return retryMonitor(fmt.Errorf("unable to unlock deposits "+
"after payment deadline: %w", err))
}
case riskUpdate, ok := <-riskUpdateChan:
if !ok {
riskUpdateChan = nil
continue
}
decisionTime := riskWatcher.decisionTime(
ctx, riskUpdate.decision,
)
f.loopIn.ConfirmationRiskDecision = riskUpdate.decision
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
switch riskUpdate.decision {
case ConfirmationRiskDecisionAccepted:
startPaymentDeadline(
riskUpdate.reason,
f.loopIn.ConfirmationRiskDecisionTime,
)
case ConfirmationRiskDecisionRejected:
return handleRiskRejected(
riskUpdate.reason, decisionTime,
)
}
case currentHeight := <-blockChan:
startLegacyFallback(
"legacy confirmation fallback", currentHeight,
)
// If the htlc is confirmed but blockChan fires before
// htlcConfChan, we would wrongfully assume that the
// htlc tx was not confirmed which would lead to
@ -824,10 +1266,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
f.Infof("htlc timed out at block height %v",
currentHeight)
// If the timeout path opened up we consider the swap
// failed and cancel the invoice.
cancelInvoice()
event, canceled := cancelInvoice("htlc timed out")
if !canceled {
return event
}
if !htlcConfirmed {
f.Infof("swap timed out, htlc not confirmed")
@ -835,24 +1277,23 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
// If the htlc hasn't confirmed but the timeout
// path opened up, and we didn't receive the
// swap payment, we consider the swap attempt to
// be failed. We cancelled the invoice, but
// don't need to unlock the deposits because
// that happened when the payment deadline was
// reached.
// be failed. Now that the invoice is canceled and
// the HTLC can no longer confirm, its deposits can be
// made available again.
err = f.unlockDeposits(ctx)
if err != nil {
return retryMonitor(fmt.Errorf("unable to unlock "+
"deposits after htlc timeout: %w", err))
}
return OnSwapTimedOut
}
// If the htlc has confirmed and the timeout path has
// opened up we sweep the funds back to us.
err = f.cfg.DepositManager.TransitionDeposits(
ctx, f.loopIn.Deposits,
deposit.OnSweepingHtlcTimeout,
deposit.SweepHtlcTimeout,
)
err = transitionDepositsToHtlcTimeout("htlc timeout")
if err != nil {
log.Errorf("unable to transition "+
"deposits to the htlc timeout "+
"sweeping state: %v", err)
return retryMonitor(err)
}
return OnSweepHtlcTimeout
@ -864,10 +1305,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
f.Errorf("block subscription error: %v", err)
return f.HandleError(err)
return retryMonitor(err)
case update, ok := <-invoiceUpdateChan:
if !ok {
if !invoiceCanceledForNonPayment {
return retryMonitor(errors.New(
"invoice update subscription closed",
))
}
invoiceUpdateChan = nil
continue
}
@ -876,13 +1323,30 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return event
}
invoice.State = update.State
if update.State == invoices.ContractCanceled {
invoiceCanceledForNonPayment = true
}
case err, ok := <-invoiceErrChan:
if !ok {
if !invoiceCanceledForNonPayment {
return retryMonitor(errors.New(
"invoice error subscription closed",
))
}
invoiceErrChan = nil
continue
}
f.Errorf("invoice subscription error: %v", err)
if ctx.Err() != nil {
return fsm.NoOp
}
return retryMonitor(fmt.Errorf(
"invoice subscription error: %w", err,
))
case <-ctx.Done():
return fsm.NoOp
@ -1017,7 +1481,10 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context,
func (f *FSM) UnlockDepositsAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
f.cancelSwapInvoice()
if err := f.cancelSwapInvoice(); err != nil {
f.Warnf("unable to cancel invoice for swap %v: %v",
f.loopIn.SwapHash, err)
}
err := f.unlockDeposits(ctx)
if err != nil {

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,11 @@ func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context,
return nil, 0, false, ErrNoAutoloopCandidate
}
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, 0, false, err
}
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
deposit.Deposited,
)

View file

@ -246,8 +246,9 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
continue
}
residualLife := confirmationHeight +
int64(csvExpiry) - int64(blockHeight)
residualLife := int64(blocksUntilDepositExpiry(
uint32(confirmationHeight), blockHeight, csvExpiry,
))
eligibleDeposits = append(
eligibleDeposits, autoloopCandidateDeposit{

View file

@ -80,6 +80,28 @@ func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
}
}
// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits
// are not treated as earlier-expiring than confirmed deposits. Their CSV timer
// has not started yet, so a same-value confirmed deposit should win the expiry
// tie-break.
func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) {
t.Parallel()
unconfirmed := makeDeposit(34, 0, 5_000, 0)
confirmed := makeDeposit(35, 0, 5_000, 200)
deposits, err := selectNoChangeDeposits(
5_000, 5_000, []*deposit.Deposit{
unconfirmed, confirmed,
}, 1_000, 100, nil,
)
require.NoError(t, err)
require.Equal(
t, []string{confirmed.OutPoint.String()},
depositOutpoints(deposits),
)
}
// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
// to understand directly than by inferring the step from a larger selector
// behavior test.

View file

@ -45,6 +45,9 @@ type AddressManager interface {
// DepositManager handles the interaction of loop-ins with deposits.
type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// GetAllDeposits returns all known deposits from the database store.
GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error)
@ -88,6 +91,11 @@ type StaticAddressLoopInStore interface {
// IsStored checks if the loop-in is already stored in the database.
IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error)
// RecordStaticAddressRiskDecision persists the server's
// confirmation-risk decision for the loop-in identified by swapHash.
RecordStaticAddressRiskDecision(ctx context.Context,
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error
// GetLoopInByHash returns the loop-in swap with the given hash.
GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) (
*StaticAddressLoopIn, error)
@ -121,4 +129,18 @@ type NotificationManager interface {
// a sweep of a static loop in that has been finished.
SubscribeStaticLoopInSweepRequests(ctx context.Context,
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk
// accepted notifications. These are sent by the server after the selected
// deposits are accepted by confirmation risk tracking.
SubscribeStaticLoopInRiskAccepted(
ctx context.Context, swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk
// rejected notifications. These are sent by the server if it aborts the
// confirmation risk wait before payment.
SubscribeStaticLoopInRiskRejected(
ctx context.Context, swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification
}

View file

@ -31,6 +31,23 @@ import (
"github.com/lightningnetwork/lnd/zpay32"
)
// ConfirmationRiskDecision records the server's decision on whether it accepts
// waiting for low-confirmation deposits before paying a static loop-in invoice.
type ConfirmationRiskDecision string
const (
// ConfirmationRiskDecisionNone means no risk decision has been received.
ConfirmationRiskDecisionNone ConfirmationRiskDecision = ""
// ConfirmationRiskDecisionAccepted means the server accepted waiting for
// deposit confirmations and the payment deadline has started.
ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted"
// ConfirmationRiskDecisionRejected means the server stopped waiting for
// deposit confirmations before paying the invoice.
ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected"
)
// StaticAddressLoopIn represents the in-memory loop-in information.
type StaticAddressLoopIn struct {
// SwapHash is the hashed preimage of the swap invoice. It represents
@ -107,6 +124,15 @@ type StaticAddressLoopIn struct {
// LastUpdateTime is the timestamp of the latest persisted state update.
LastUpdateTime time.Time
// ConfirmationRiskDecision records the server's persisted decision on
// low-confirmation deposit risk.
ConfirmationRiskDecision ConfirmationRiskDecision
// ConfirmationRiskDecisionTime is when loopd persisted the server risk
// decision. It is used to reconstruct payment-deadline timeouts after
// restart.
ConfirmationRiskDecisionTime time.Time
// state is the current state of the swap.
state fsm.StateType

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"math"
"slices"
"sort"
"sync/atomic"
@ -629,6 +630,11 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
selectedDeposits []*deposit.Deposit
)
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w", err)
}
// Determine which deposits to use for the loop-in swap. If none are
// selected by the client, we will coin-select them based on the amount.
switch {
@ -845,11 +851,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 +877,27 @@ 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 := deposits[i].GetConfirmationHeight()
jConfirmationHeight := 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(
uint32(iConfirmationHeight), blockHeight,
csvExpiry,
)
jExp := blocksUntilDepositExpiry(
uint32(jConfirmationHeight), blockHeight,
csvExpiry,
)
return iExp < jExp
}
@ -910,20 +929,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.
@ -366,6 +393,10 @@ type mockDepositManager struct {
byOutpoint map[string]*deposit.Deposit
}
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) {
@ -493,6 +524,13 @@ func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) {
return false, nil
}
// RecordStaticAddressRiskDecision implements Store for manager tests.
func (s *mockStore) RecordStaticAddressRiskDecision(context.Context,
lntypes.Hash, ConfirmationRiskDecision) error {
return nil
}
func (s *mockStore) GetLoopInByHash(_ context.Context,
swapHash lntypes.Hash) (*StaticAddressLoopIn, error) {

View file

@ -0,0 +1,196 @@
package loopin
import (
"bytes"
"context"
"time"
"github.com/lightningnetwork/lnd/lntypes"
)
// confirmationRiskUpdate is the normalized result of a server confirmation-risk
// notification.
type confirmationRiskUpdate struct {
decision ConfirmationRiskDecision
reason string
}
// confirmationRiskWatcher normalizes static loop-in confirmation risk
// notifications and restores the durable decision timestamp.
type confirmationRiskWatcher struct {
swapHash lntypes.Hash
store StaticAddressLoopInStore
notificationManager NotificationManager
logWarnf func(string, ...any)
}
// newConfirmationRiskWatcher creates a helper that handles confirmation-risk
// notification plumbing for a single static loop-in swap.
func newConfirmationRiskWatcher(cfg *Config, swapHash lntypes.Hash,
warnf func(string, ...any)) *confirmationRiskWatcher {
return &confirmationRiskWatcher{
swapHash: swapHash,
store: cfg.Store,
notificationManager: cfg.NotificationManager,
logWarnf: warnf,
}
}
// warnf logs through the FSM-scoped logger when one is available.
func (w *confirmationRiskWatcher) warnf(format string, args ...any) {
if w.logWarnf != nil {
w.logWarnf(format, args...)
return
}
log.Warnf(format, args...)
}
// subscribe subscribes to accepted and rejected confirmation-risk notifications
// and emits normalized updates for the watcher's swap hash.
func (w *confirmationRiskWatcher) subscribe(ctx context.Context) (
<-chan confirmationRiskUpdate, func()) {
if w.notificationManager == nil {
return nil, func() {}
}
notificationCtx, cancel := context.WithCancel(ctx)
riskAcceptedChan := w.notificationManager.SubscribeStaticLoopInRiskAccepted(
notificationCtx, w.swapHash,
)
riskRejectedChan := w.notificationManager.SubscribeStaticLoopInRiskRejected(
notificationCtx, w.swapHash,
)
riskUpdates := make(chan confirmationRiskUpdate, 1)
go func() {
defer close(riskUpdates)
for {
select {
case riskAccepted, ok := <-riskAcceptedChan:
if !ok {
riskAcceptedChan = nil
continue
}
if riskAccepted == nil || !bytes.Equal(
riskAccepted.SwapHash, w.swapHash[:],
) {
continue
}
update := confirmationRiskUpdate{
decision: ConfirmationRiskDecisionAccepted,
reason: "risk accepted notification",
}
select {
case riskUpdates <- update:
case <-notificationCtx.Done():
return
}
case riskRejected, ok := <-riskRejectedChan:
if !ok {
riskRejectedChan = nil
continue
}
if riskRejected == nil || !bytes.Equal(
riskRejected.SwapHash, w.swapHash[:],
) {
continue
}
update := confirmationRiskUpdate{
decision: ConfirmationRiskDecisionRejected,
reason: "risk rejection",
}
select {
case riskUpdates <- update:
case <-notificationCtx.Done():
return
}
case <-notificationCtx.Done():
return
}
}
}()
return riskUpdates, cancel
}
// durableDecisionTime returns the durable decision timestamp, recording the
// decision first if the notification was replayed before it could be persisted.
// The bool is false when a configured store could not durably record or reload
// the decision.
func (w *confirmationRiskWatcher) durableDecisionTime(ctx context.Context,
decision ConfirmationRiskDecision) (time.Time, bool) {
now := time.Now()
if w.store == nil {
return now, true
}
storedLoopIn, err := w.store.GetLoopInByHash(ctx, w.swapHash)
if err != nil {
w.warnf("unable to reload persisted risk decision for swap %v: %v",
w.swapHash, err)
return time.Time{}, false
}
if storedLoopIn == nil {
return time.Time{}, false
}
hasPersistedDecision :=
storedLoopIn.ConfirmationRiskDecision == decision &&
!storedLoopIn.ConfirmationRiskDecisionTime.IsZero()
if !hasPersistedDecision {
err = w.store.RecordStaticAddressRiskDecision(
ctx, w.swapHash, decision,
)
if err != nil {
w.warnf("unable to persist replayed risk decision for "+
"swap %v: %v", w.swapHash, err)
return time.Time{}, false
}
storedLoopIn, err = w.store.GetLoopInByHash(ctx, w.swapHash)
if err != nil {
w.warnf("unable to reload persisted risk decision for "+
"swap %v: %v", w.swapHash, err)
return time.Time{}, false
}
if storedLoopIn == nil ||
storedLoopIn.ConfirmationRiskDecision != decision ||
storedLoopIn.ConfirmationRiskDecisionTime.IsZero() {
return time.Time{}, false
}
}
return storedLoopIn.ConfirmationRiskDecisionTime, true
}
// decisionTime retains the best-effort behavior used for server notifications.
func (w *confirmationRiskWatcher) decisionTime(ctx context.Context,
decision ConfirmationRiskDecision) time.Time {
decisionTime, ok := w.durableDecisionTime(ctx, decision)
if !ok {
return time.Now()
}
return decisionTime
}

View file

@ -0,0 +1,121 @@
package loopin
import (
"context"
"testing"
"time"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/stretchr/testify/require"
)
// TestConfirmationRiskWatcherSubscribeFiltersSwapHash verifies that the watcher
// only emits normalized decisions for the swap it was created for.
func TestConfirmationRiskWatcherSubscribeFiltersSwapHash(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
swapHash := lntypes.Hash{1, 2, 3}
otherHash := lntypes.Hash{3, 2, 1}
notificationMgr := &mockNotificationManager{
riskAccepted: make(
chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification, 1,
),
riskRejected: make(
chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification, 1,
),
}
watcher := newConfirmationRiskWatcher(
&Config{NotificationManager: notificationMgr}, swapHash,
t.Logf,
)
updates, stop := watcher.subscribe(ctx)
defer stop()
notificationMgr.riskAccepted <- &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: otherHash[:],
}
select {
case update := <-updates:
t.Fatalf("received wrong-hash risk update: %v", update)
case <-time.After(100 * time.Millisecond):
}
notificationMgr.riskRejected <- &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
}
select {
case update := <-updates:
require.Equal(t, ConfirmationRiskDecisionRejected,
update.decision)
require.Equal(t, "risk rejection", update.reason)
case <-ctx.Done():
t.Fatalf("risk update not received: %v", ctx.Err())
}
}
// TestConfirmationRiskWatcherDecisionTimeRestoration verifies that the watcher
// preserves existing persisted decision timestamps and records missing ones.
func TestConfirmationRiskWatcherDecisionTimeRestoration(t *testing.T) {
t.Parallel()
ctx := t.Context()
swapHash := lntypes.Hash{4, 5, 6}
decisionTime := time.Unix(123, 0).UTC()
store := &recordingRiskStore{
mockStore: &mockStore{
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
swapHash: {
ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted,
ConfirmationRiskDecisionTime: decisionTime,
},
},
},
decisions: make(chan ConfirmationRiskDecision, 1),
}
watcher := newConfirmationRiskWatcher(&Config{Store: store}, swapHash,
t.Logf)
restoredTime := watcher.decisionTime(
ctx, ConfirmationRiskDecisionAccepted,
)
require.True(t, restoredTime.Equal(decisionTime))
select {
case decision := <-store.decisions:
t.Fatalf("persisted already-recorded decision: %v", decision)
default:
}
store.loopIns[swapHash] = &StaticAddressLoopIn{}
recordedTime := watcher.decisionTime(
ctx, ConfirmationRiskDecisionRejected,
)
require.False(t, recordedTime.IsZero())
select {
case decision := <-store.decisions:
require.Equal(t, ConfirmationRiskDecisionRejected, decision)
case <-time.After(time.Second):
t.Fatal("missing risk decision was not persisted")
}
require.Equal(t, ConfirmationRiskDecisionRejected,
store.loopIns[swapHash].ConfirmationRiskDecision)
require.True(t, recordedTime.Equal(
store.loopIns[swapHash].ConfirmationRiskDecisionTime,
))
}

View file

@ -27,6 +27,9 @@ var (
// ErrInvalidOutpoint is returned when an outpoint contains the outpoint
// separator.
ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator")
// ErrLoopInNotFound is returned when a loop-in swap is not stored.
ErrLoopInNotFound = errors.New("static address loop-in not found")
)
// Querier is the interface that contains all the queries generated by sqlc for
@ -51,6 +54,11 @@ type Querier interface {
UpdateStaticAddressLoopIn(ctx context.Context,
arg sqlc.UpdateStaticAddressLoopInParams) error
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
// decision for a loop-in swap.
RecordStaticAddressRiskDecision(ctx context.Context,
arg sqlc.RecordStaticAddressRiskDecisionParams) error
// GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash.
GetStaticAddressLoopInSwap(ctx context.Context,
swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error)
@ -361,6 +369,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
)
}
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
// decision for a static address loop-in. The timestamp is written by the store
// so recovery can reconstruct the remaining payment deadline from one durable
// clock source.
func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context,
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error {
if decision != ConfirmationRiskDecisionAccepted &&
decision != ConfirmationRiskDecisionRejected {
return errors.New("unknown confirmation risk decision")
}
params := sqlc.RecordStaticAddressRiskDecisionParams{
SwapHash: swapHash[:],
ConfirmationRiskDecision: string(decision),
ConfirmationRiskDecisionTime: sql.NullTime{
Time: s.clock.Now(),
Valid: true,
},
}
return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
func(q Querier) error {
stored, err := q.IsStored(ctx, swapHash[:])
if err != nil {
return err
}
if !stored {
return ErrLoopInNotFound
}
return q.RecordStaticAddressRiskDecision(ctx, params)
},
)
}
func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context,
updateAmounts map[lntypes.Hash]btcutil.Amount) error {
@ -584,6 +629,9 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
DepositOutpoints: depositOutpoints,
SelectedAmount: btcutil.Amount(swap.SelectedAmount),
Fast: swap.Fast,
ConfirmationRiskDecision: ConfirmationRiskDecision(
swap.ConfirmationRiskDecision,
),
HtlcTxFeeRate: chainfee.SatPerKWeight(
swap.HtlcTxFeeRateSatKw,
),
@ -591,6 +639,10 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash,
Deposits: depositList,
}
if swap.ConfirmationRiskDecisionTime.Valid {
loopIn.ConfirmationRiskDecisionTime =
swap.ConfirmationRiskDecisionTime.Time
}
if len(updates) > 0 {
lastUpdate := updates[len(updates)-1]

View file

@ -349,6 +349,83 @@ func TestCreateLoopIn(t *testing.T) {
require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()},
swap.DepositOutpoints)
require.Equal(t, SignHtlcTx, swap.GetState())
require.Equal(
t, ConfirmationRiskDecisionNone,
swap.ConfirmationRiskDecision,
)
decisionTime := time.Unix(123, 0).UTC()
testClock.SetTime(decisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionAccepted,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
// Replaying the same decision must retain its original deadline anchor.
laterDecisionTime := decisionTime.Add(time.Hour)
testClock.SetTime(laterDecisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionAccepted,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
// A different decision is a new event and receives a new timestamp.
rejectedDecisionTime := laterDecisionTime.Add(time.Hour)
testClock.SetTime(rejectedDecisionTime)
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionRejected,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionRejected,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
rejectedDecisionTime,
))
// Rejected is terminal: a racing synthetic acceptance must not replace
// the server's rejection or move its deadline anchor.
testClock.SetTime(rejectedDecisionTime.Add(time.Hour))
err = swapStore.RecordStaticAddressRiskDecision(
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(
t, ConfirmationRiskDecisionRejected,
swap.ConfirmationRiskDecision,
)
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
rejectedDecisionTime,
))
err = swapStore.RecordStaticAddressRiskDecision(
ctx, lntypes.Hash{0x9, 0x9, 0x9},
ConfirmationRiskDecisionRejected,
)
require.ErrorIs(t, err, ErrLoopInNotFound)
require.Len(t, swap.Deposits, 2)

View file

@ -12,6 +12,9 @@ import (
)
type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// AllOutpointsActiveDeposits returns all deposits that are in the
// given state. If the state filter is fsm.StateTypeNone, all deposits
// are returned.

View file

@ -267,11 +267,6 @@ func (m *Manager) OpenChannel(ctx context.Context,
).FeePerKWeight()
}
// There are three ways in which we select deposits to open a channel
// with. 1.) The user manually selects the deposits. 2.) The user only
// selects a local channel amount in which case we coin-select deposits
// to cover for it. 3.) The user selects the fundmax flag, in which case
// we select all deposits to fund the channel.
if len(req.Outpoints) > 0 {
// Ensure that the deposits are in a state in which they are
// available for a channel open.
@ -288,6 +283,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, fmt.Errorf("%w in request", err)
}
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, allActive =
m.cfg.DepositManager.AllOutpointsActiveDeposits(
outpoints, deposit.Deposited,
@ -296,6 +297,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, ErrOpeningChannelUnavailableDeposits
}
} else {
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
// We have to select the deposits that are used to fund the
// channel.
deposits, err = m.cfg.DepositManager.GetActiveDepositsInState(
@ -305,6 +312,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 a local funding amount is set, coin-select deposits to
// cover it. Otherwise fundmax uses all available deposits.
if req.LocalFundingAmount != 0 {
@ -319,6 +330,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.GetConfirmationHeight() <= 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.
@ -394,6 +413,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.GetConfirmationHeight() <= 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,12 +29,17 @@ type transitionCall struct {
}
type mockDepositManager struct {
activeDeposits []*deposit.Deposit
openingDeposits []*deposit.Deposit
getErr error
transitionErrs map[fsm.EventType]error
calls []transitionCall
}
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
fsm.StateType) ([]*deposit.Deposit, bool) {
@ -44,15 +49,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 +473,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

@ -21,14 +21,24 @@ type AddressManager interface {
}
type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// GetActiveDepositsInState returns all active deposits in the given
// state.
GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit,
error)
// AllOutpointsActiveDeposits returns all active deposits referenced by
// the outpoints if every deposit is active and in the given state.
AllOutpointsActiveDeposits(outpoints []wire.OutPoint,
stateFilter fsm.StateType) ([]*deposit.Deposit, bool)
// TransitionDeposits transitions the deposits with the given event and
// waits until they reach the expected final state.
TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit,
event fsm.EventType, expectedFinalState fsm.StateType) error
// UpdateDeposit persists the current deposit fields.
UpdateDeposit(ctx context.Context, d *deposit.Deposit) error
}

View file

@ -320,6 +320,11 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
allWithdrawing bool
)
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return "", "", fmt.Errorf("unable to refresh deposits: %w", err)
}
// Ensure that the deposits are in a state in which they can be
// withdrawn.
deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits(
@ -381,10 +386,16 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
}
}
var (
withdrawalAddress btcutil.Address
err error
)
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.GetConfirmationHeight() <= 0 {
return "", "", fmt.Errorf("can't withdraw, " +
"unconfirmed deposits can't be withdrawn")
}
}
var withdrawalAddress btcutil.Address
// Check if the user provided an address to withdraw to. If not, we'll
// generate a new address for them.

View file

@ -155,7 +155,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
return nil, fmt.Errorf("invoice: %x not found", hash)
}
return inv, nil
invoiceCopy := *inv
return &invoiceCopy, nil
}
// ListTransactions returns all known transactions of the backing lnd node.

View file

@ -225,6 +225,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
s.lock.Unlock()
}
// SetInvoice stores a copy of the given invoice in the mock invoice store.
func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
s.lock.Lock()
defer s.lock.Unlock()
invoiceCopy := *invoice
s.Invoices[invoice.Hash] = &invoiceCopy
}
// IsDone checks whether all channels have been fully emptied. If not this may
// indicate unexpected behaviour of the code under test.
func (s *LndMockServices) IsDone() error {