Compare commits

...

15 commits

Author SHA1 Message Date
Slyghtning
978a2cbe05
cmd/loop: update static loop-in replay flow
Delay the static address summary RPC until after validation and quote fee
checks. This keeps early error paths on their previous gRPC flow.

Add summary RPC events to the static loop-in sessions that reach the
confirmation prompt or swap dispatch, where the CLI needs the expiry
horizon for low-confirmation warnings.
2026-05-22 12:16:29 +02:00
Slyghtning
ec42d48aac
notifications: deduplicate risk fanout
Risk accepted and rejected notifications had nearly identical parsing,
persistence, cache update, and subscriber fanout code. The duplication made it
easy for the two decision paths to drift.

Introduce a shared risk-decision handler that validates the swap hash, persists
the decision, updates the matching cache, clears the opposite cache, and fans
out only to the subscriber for that swap.

Keep risk-decision delivery best-effort for slow subscribers, while queued
delivery remains limited to notification types that must not be dropped. Fold
the queue tests through a common helper so both queued notification paths keep
the same behavior.
2026-05-22 11:22:45 +02:00
Slyghtning
7a137df098
notifications: queue blocking fanout
Some notification types are work requests and must not be dropped when a
subscriber channel is temporarily full. Sending them synchronously from the
global notification handler can stall the stream while a slow subscriber holds
back reads.

Add a per-subscriber queue for blocking notification classes and deliver those
notifications from a worker tied to the subscriber context. Keep direct sends as
the fallback for subscribers that do not need queued delivery.

Use the queued path for static loop-in sweep requests and unfinished swap
notifications, and fix subscriber removal to compare channel identity now that
subscribers contain function fields.
2026-05-22 11:22:45 +02:00
Slyghtning
9fb4a5d606
staticaddr/loopin: persist risk decisions
Store the server's static loop-in confirmation-risk decision and the time it
was received. This lets recovered swaps reconstruct whether payment waiting had
already started and how much of the payment timeout remains.

Wire notification handling to persist accepted and rejected decisions before
caching and forwarding them. If the swap row is not present yet, the
notification is still cached so the per-swap waiter can replay and store the
decision later.

Recover accepted decisions by starting the payment deadline from the persisted
decision time, and recover rejected decisions by canceling the invoice and
failing the swap instead of waiting forever.
2026-05-22 11:22:44 +02:00
Slyghtning
a741aab390
staticaddr/loopin: list failed swaps by state
GetStaticAddressLoopInSwapsByStates passes a comma-separated
state list into a SQL LIKE membership check. The query wraps the
input with commas before matching latest update states as
comma-delimited tokens.

Wrapping that list in braces meant the first and last states
were not bounded by commas, so boundary entries in a state set
could be missed. In particular, Failed is the last final
static-address loop-in state, which made final-state queries skip
failed swaps.

Drop the braces from the serialized state list and extend the
SQL store test with a failed swap so the final-state boundary is
covered.
2026-05-22 11:22:44 +02:00
Slyghtning
e6404bb867
staticaddr/loopin: handle risk rejection notification
Add client handling for the server's static loop-in risk-rejected notification.
If the server aborts confirmation-risk waiting before payment, the client fails
the local swap instead of waiting for a payment deadline that will never start.

Cache rejected notifications by swap hash using the same replay path as accepted
notifications, and clear the opposite cached state when a final risk decision is
received. This keeps reconnect and subscription-order races from stranding the
client in the risk wait.
2026-05-22 11:22:43 +02:00
Slyghtning
aa77682e07
staticaddr/loopin: wait for risk acceptance notification
Wait for the server's static loop-in risk-accepted notification before starting
the client payment deadline. The server may intentionally hold the swap at the
confirmation-risk gate after HTLC signing, and the client deadline should not
run while that server-side wait is still in progress.

Cache risk-accepted notifications by swap hash inside the local notification
manager and replay them to the per-swap subscriber. This covers both reconnects
and the internal race where the global notification stream receives the server
event before the static loop-in FSM registers its waiter.
2026-05-22 11:22:43 +02:00
Slyghtning
a7bff02fd8
staticaddr: harden client deposit readiness
ListUnspentDeposits now reports only wallet UTXOs that have an active Deposited
record. That matches the static loop-in admission path and avoids exposing
wallet-seen outputs that are not ready for loop-in selection.

Make local notification fan-out non-blocking for best-effort categories so a
slow subscriber cannot stall the notification manager while it holds the
subscriber lock. Static loop-in sweep signing requests remain blocking because
they are work requests required for sweepbatcher presigning and must not be
dropped.
2026-05-22 11:22:43 +02:00
Slyghtning
7287da1559
staticaddr: cancel loop-ins when deposit inputs vanish
Keep replacement UTXOs as fresh deposits while preserving the original deposit
record and selected outpoint snapshot for pending swaps.

Before signing a static loop-in HTLC, check each original selected outpoint with
GetTxOut(..., includeMempool=true). Cancel the pending invoice only when that
check reports an original outpoint unavailable; lookup errors fail the action
without canceling so transient chain backend errors do not incorrectly abandon
the swap.

Keep recovered loop-ins using their stored outpoint snapshot and cover
replacement discovery and cancellation in tests.
2026-05-22 11:22:42 +02:00
Slyghtning
ab6dcdfb1f
staticaddr/deposit: async finalization cleanup
FinalizeDepositAction only needs to tell the manager to remove the FSM from its
active set, but the old synchronous send was still tied to the caller context
and could race with request cancellation or a busy manager loop.

Send the cleanup notification asynchronously and tie it to the FSM lifetime
instead. Withdrawal completion no longer blocks while deposit locks are held
just because the original request context was canceled.
2026-05-22 11:22:42 +02:00
Slyghtning
afb7c80a96
staticaddr/loopin: cancel orphan invoice when init fails early
If InitHtlcAction creates the private swap invoice but fails before the loop-in
is stored, the retry path otherwise leaves behind a live orphan invoice.

Cancel that invoice on the early error path with a detached, timeout-limited
context, and reuse the same helper when tearing down the monitor path. This
keeps failed initialization attempts from leaving invoices that no local swap
can complete.
2026-05-22 11:22:42 +02:00
Slyghtning
e6e48978ef
cmd/loop: warn for auto-selected low-conf deposits
Remove the old "no confirmed deposits available" error now that mempool
deposits are listed immediately and can be selected for static loop-ins.

Reproduce the server static-address deposit selection order in the CLI using
the already-returned deposit metadata. This keeps the low-confirmation warning
focused on the deposits auto-selection would actually choose, so users only see
it when the swap payment may wait for the server confirmation-risk policy.
2026-05-22 11:22:42 +02:00
Slyghtning
3a1429a6a0
staticaddr: apply confirmation policy by flow
Allow static loop-ins to select unconfirmed deposits because their CSV timeout
has not started yet, while still preferring confirmed outputs during automatic
selection.

Keep confirmed-input requirements for channel opens and withdrawals now that
Deposited includes mempool outputs. Filter unconfirmed deposits out of automatic
selection for those flows and fail manual requests that reference them, so the
client does not build PSBTs or withdrawal attempts with unusable inputs.

Treat deposit.MinConfs as the legacy readiness threshold rather than the single
source of truth for all flows. Loop-in readiness is now governed by server
confirmation-risk policy, while withdrawals and channel opens keep their
confirmed-input checks.
2026-05-22 11:22:41 +02:00
Slyghtning
d561b74216
staticaddr/deposit: replay startup block to recovered deposits
The deposit manager consumes one block epoch before recovered deposit FSMs are
started. That left already-expired recovered deposits idle until another block
arrived.

Remember the startup height and deliver it to active deposit FSMs after
recovery and reconciliation have finished. Move the block notification fanout
into a helper so startup replay and normal block handling use the same path.

Add coverage that starts the manager with a recovered deposit at its expiry
height and verifies the expiry sweep is signed and published immediately.
2026-05-22 11:22:41 +02:00
Slyghtning
502d298643
staticaddr: track unconfirmed deposits
Surface static-address deposits as soon as they appear in the wallet instead
of waiting for the old six-confirmation readiness threshold.

Reconcile the wallet view on startup, on each block, and on the polling ticker
so mempool deposits are created immediately. Backfill the first confirmation
height once those outputs confirm, protect unconfirmed deposits from expiry,
and mark vanished unconfirmed outpoints as Replaced so RBFed-away deposits stop
showing up in RPCs.

Expose the new state through static-address RPCs by deriving availability and
summary totals from stored deposit state, reporting sensible expiry data for
unconfirmed outputs, and hiding Replaced records from normal listings.
2026-05-22 11:22:41 +02:00
44 changed files with 5712 additions and 378 deletions

View file

@ -4,14 +4,17 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/lightninglabs/loop/swapserverrpc"
lndcommands "github.com/lightningnetwork/lnd/cmd/commands"
"github.com/lightningnetwork/lnd/input"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli/v3"
)
@ -553,11 +556,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

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

@ -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)
@ -601,6 +621,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
depositStore := deposit.NewSqlStore(baseDb)
depoCfg := &deposit.ManagerConfig{
AddressManager: staticAddressManager,
ChainKit: d.lnd.ChainKit,
Store: depositStore,
WalletKit: d.lnd.WalletKit,
ChainNotifier: d.lnd.ChainNotifier,
@ -641,12 +662,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,
@ -678,6 +693,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
Store: staticAddressLoopInStore,
WalletKit: d.lnd.WalletKit,
ChainNotifier: d.lnd.ChainNotifier,
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
NotificationManager: notificationManager,
ChainParams: d.lnd.ChainParams,
Signer: d.lnd.Signer,

View file

@ -976,15 +976,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 filters out deposits that are no
// longer visible to the user, such as Replaced records. For a manual
// quote we additionally require the current state to be Deposited so a
// 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,
)
@ -1692,58 +1701,35 @@ 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())
}
// 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.
@ -1790,8 +1776,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:
@ -1814,6 +1801,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.ConfirmationHeight <= 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.
@ -1838,7 +1844,8 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
var filteredDeposits []*looprpc.Deposit
if len(outpoints) > 0 {
f := func(d *deposit.Deposit) bool {
return slices.Contains(outpoints, d.OutPoint.String())
return isVisibleDeposit(d) &&
slices.Contains(outpoints, d.OutPoint.String())
}
filteredDeposits = filter(allDeposits, f)
@ -1848,6 +1855,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
}
} else {
f := func(d *deposit.Deposit) bool {
if !isVisibleDeposit(d) {
return false
}
if req.StateFilter == looprpc.DepositState_UNKNOWN_STATE {
// Per default, we return deposits in all
// states.
@ -1982,9 +1993,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
protoDeposits = make([]*looprpc.Deposit, 0, len(ds))
for _, d := range ds {
state := toClientDepositState(d.GetState())
blocksUntilExpiry := d.ConfirmationHeight +
int64(addrParams.Expiry) -
int64(lndInfo.BlockHeight)
blocksUntilExpiry := depositBlocksUntilExpiry(
d.ConfirmationHeight, addrParams.Expiry,
int64(lndInfo.BlockHeight),
)
pd := &looprpc.Deposit{
Id: d.ID[:],
@ -2033,6 +2045,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
if err != nil {
return nil, err
}
allDeposits = filterDeposits(allDeposits, isVisibleDeposit)
var (
totalNumDeposits = len(allDeposits)
@ -2045,23 +2058,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.ConfirmationHeight <= 0 {
valueUnconfirmed += value
} else {
valueDeposited += value
}
case deposit.Expired:
valueExpired += value
@ -2211,13 +2217,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,
@ -2247,6 +2267,30 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
type filterFunc func(deposits *deposit.Deposit) bool
// filterDeposits returns all deposits accepted by the given predicate.
func filterDeposits(deposits []*deposit.Deposit,
f filterFunc) []*deposit.Deposit {
filtered := make([]*deposit.Deposit, 0, len(deposits))
for _, deposit := range deposits {
if !f(deposit) {
continue
}
filtered = append(filtered, deposit)
}
return filtered
}
// isVisibleDeposit returns true if a deposit should appear in normal listings.
func isVisibleDeposit(d *deposit.Deposit) bool {
// Replaced deposits are kept in the DB as history, but they should disappear
// from normal deposit listings and summary totals because the underlying
// outpoint is no longer present in the wallet and cannot be spent.
return d.GetState() != deposit.Replaced
}
func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit {
var clientDeposits []*looprpc.Deposit
for _, d := range deposits {

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,225 @@
package loopd
import (
"context"
"testing"
"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/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
}
// 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{
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
}
// TestListStaticAddressDepositsHidesReplaced verifies replaced deposits are
// hidden from normal deposit listings.
func TestListStaticAddressDepositsHidesReplaced(t *testing.T) {
t.Parallel()
replaced := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
}
replaced.SetState(deposit.Replaced)
available := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
}
available.SetState(deposit.Deposited)
addrMgr, lnd := newTestStaticAddressContext(t)
server := &swapClientServer{
depositManager: newTestDepositManager(replaced, 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,
)
}
// TestGetStaticAddressSummaryIgnoresReplaced verifies replaced deposits are
// excluded from static address summary totals.
func TestGetStaticAddressSummaryIgnoresReplaced(t *testing.T) {
t.Parallel()
replaced := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
},
Value: btcutil.Amount(1_000),
}
replaced.SetState(deposit.Replaced)
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(
replaced, 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

@ -1071,7 +1071,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,
error) {
@ -1120,11 +1120,11 @@ 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(
@ -1142,17 +1142,19 @@ func TestListUnspentDeposits(t *testing.T) {
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
}
// 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{
@ -1165,8 +1167,8 @@ func TestListUnspentDeposits(t *testing.T) {
)
require.NoError(t, err)
// 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{}{}
@ -1174,25 +1176,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{
@ -1205,22 +1205,20 @@ func TestListUnspentDeposits(t *testing.T) {
)
require.NoError(t, err)
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) {
// Confirmed UTXO not present in store should be excluded.
t.Run("confirmed utxo not in store is excluded", 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})
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
// Empty store (no states for any outpoint).
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
@ -1235,12 +1233,6 @@ func TestListUnspentDeposits(t *testing.T) {
)
require.NoError(t, err)
// 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,
)
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
require.Empty(t, resp.Utxos)
})
}

View file

@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS deposits (
amount BIGINT NOT NULL,
-- confirmation_height is the absolute height at which the deposit was
-- confirmed.
-- confirmed. A value of 0 means the deposit is still unconfirmed.
confirmation_height BIGINT NOT NULL,
-- timeout_sweep_pk_script is the public key script that will be used to
@ -45,4 +45,4 @@ CREATE TABLE IF NOT EXISTS deposit_updates (
-- update_timestamp is the timestamp of the update.
update_timestamp TIMESTAMP NOT NULL
);
);

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,9 @@
-- confirmation_risk_decision records the server's confirmation-risk decision
-- for a static address loop-in. The empty string means no decision has been
-- received yet.
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.
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,14 @@ SET
WHERE
swap_hash = $1;
-- name: RecordStaticAddressRiskDecision :exec
UPDATE static_address_swaps
SET
confirmation_risk_decision = $2,
confirmation_risk_decision_time = $3
WHERE
swap_hash = $1;
-- name: InsertStaticAddressMetaUpdate :exec
INSERT INTO static_address_swap_updates (
swap_hash,
@ -150,4 +158,3 @@ WHERE

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,26 @@ 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 = $3
WHERE
swap_hash = $1
`
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
@ -64,6 +72,13 @@ type Config struct {
// MinAliveConnTime is the minimum time that the connection to the
// server needs to be alive before we consider it a successful.
MinAliveConnTime time.Duration
// 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
@ -76,6 +91,12 @@ type Manager struct {
hasL402 bool
subscribers map[NotificationType][]subscriber
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification
}
// NewManager creates a new notification manager.
@ -88,12 +109,115 @@ 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,
),
}
}
type subscriber struct {
subCtx context.Context
recvChan any
swapHash *lntypes.Hash
enqueue func(any)
}
// newNotificationQueue creates a per-subscriber FIFO delivery function.
func newNotificationQueue[T any](ctx context.Context,
recvChan chan T) func(any) {
type queue struct {
sync.Mutex
pending []T
notify chan struct{}
}
q := &queue{
notify: make(chan struct{}, 1),
}
go func() {
defer func() {
if recover() != nil {
log.Debugf("subscriber channel closed before " +
"notification delivery")
}
}()
for {
q.Lock()
if len(q.pending) == 0 {
q.Unlock()
select {
case <-q.notify:
continue
case <-ctx.Done():
return
}
}
ntfn := q.pending[0]
q.pending = q.pending[1:]
q.Unlock()
select {
case recvChan <- ntfn:
case <-ctx.Done():
return
}
}
}()
return func(ntfn any) {
typedNtfn, ok := ntfn.(T)
if !ok {
log.Warnf("unexpected notification type %T", ntfn)
return
}
q.Lock()
q.pending = append(q.pending, typedNtfn)
q.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
}
// queueNotification queues or synchronously sends a must-deliver notification.
func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
if sub.enqueue != nil {
sub.enqueue(ntfn)
return
}
select {
case recvChan <- ntfn:
case <-sub.subCtx.Done():
}
}
// 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.
@ -128,6 +252,7 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
enqueue: newNotificationQueue(ctx, notifChan),
}
m.addSubscriber(NotificationTypeStaticLoopInSweepRequest, sub)
@ -143,6 +268,80 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
return notifChan
}
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
notifChan := make(
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append(
m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub,
)
if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok {
notifChan <- ntfn
delete(m.staticLoopInRiskAccepted, swapHash)
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
m.Lock()
delete(m.staticLoopInRiskAccepted, swapHash)
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
notifChan := make(
chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append(
m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub,
)
if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok {
notifChan <- ntfn
delete(m.staticLoopInRiskRejected, swapHash)
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub)
m.Lock()
delete(m.staticLoopInRiskRejected, swapHash)
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
@ -153,6 +352,7 @@ func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
enqueue: newNotificationQueue(ctx, notifChan),
}
m.addSubscriber(NotificationTypeUnfinishedSwap, sub)
@ -277,7 +477,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
}
@ -287,9 +487,69 @@ 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), notifySubscriber func(subscriber)) {
decision := staticLoopInRiskDecisionName(accepted)
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)
}
}
m.Lock()
defer m.Unlock()
if hasSwapHash {
cacheDecision(swapHash)
}
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) {
@ -303,7 +563,13 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerReservationNotification)
recvChan <- reservationNtfn
select {
case recvChan <- reservationNtfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping reservation " +
"notification for slow subscriber")
}
}
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInSweep: // nolint: lll
// We'll forward the static loop in sweep request to all
@ -316,9 +582,63 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInSweepNotification)
recvChan <- staticLoopInSweepRequestNtfn
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) {
m.staticLoopInRiskAccepted[swapHash] =
riskAcceptedNtfn
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) {
m.staticLoopInRiskRejected[swapHash] =
riskRejectedNtfn
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.
@ -330,7 +650,7 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerUnfinishedSwapNotification)
recvChan <- unfinishedSwapNtfn
queueNotification(sub, recvChan, unfinishedSwapNtfn)
}
default:
@ -353,7 +673,7 @@ func (m *Manager) removeSubscriber(notifType NotificationType, sub subscriber) {
subs := m.subscribers[notifType]
newSubs := make([]subscriber, 0, len(subs))
for _, s := range subs {
if s != sub {
if s.recvChan != sub.recvChan {
newSubs = append(newSubs, s)
}
}

View file

@ -18,7 +18,7 @@ import (
var (
testReservationId = []byte{0x01, 0x02}
testReservationId2 = []byte{0x01, 0x02}
testReservationId2 = []byte{0x03, 0x04}
)
// mockNotificationsClient implements the NotificationsClient interface for testing.
@ -188,6 +188,527 @@ func getTestNotification(resId []byte) *swapserverrpc.SubscribeNotificationsResp
}
}
// unfinishedSwapNotification builds an unfinished swap notification.
func unfinishedSwapNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_UnfinishedSwap{
UnfinishedSwap: &swapserverrpc.
ServerUnfinishedSwapNotification{
SwapHash: swapHash[:],
},
},
}
}
// staticLoopInSweepNotification builds a static loop-in sweep notification.
func staticLoopInSweepNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInSweep{
StaticLoopInSweep: &swapserverrpc.
ServerStaticLoopInSweepNotification{
SwapHash: swapHash[:],
},
},
}
}
// 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_SlowSubscriberDoesNotBlock tests that a subscriber with a full
// notification channel does not block delivery to other subscribers.
func TestManager_SlowSubscriberDoesNotBlock(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
slowCtx, slowCancel := context.WithCancel(t.Context())
defer slowCancel()
slowChan := mgr.SubscribeReservations(slowCtx)
fastCtx, fastCancel := context.WithCancel(t.Context())
defer fastCancel()
fastChan := mgr.SubscribeReservations(fastCtx)
firstNotif := getTestNotification(testReservationId)
mgr.handleNotification(t.Context(), firstNotif)
received := <-fastChan
require.Equal(t, testReservationId, received.ReservationId)
secondNotif := getTestNotification(testReservationId2)
done := make(chan struct{})
go func() {
mgr.handleNotification(t.Context(), secondNotif)
close(done)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
select {
case received = <-fastChan:
require.Equal(t, testReservationId2, received.ReservationId)
case <-time.After(time.Second):
t.Fatal("fast subscriber did not receive notification")
}
require.Len(t, slowChan, 1)
}
// TestManager_UnfinishedSwapNotificationWaitsForSubscriber verifies that
// unfinished swap recovery notifications are not dropped when the local
// subscriber is briefly behind.
func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) {
t.Parallel()
assertQueuedSwapHashNotifications(
t,
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
ServerUnfinishedSwapNotification {
return mgr.SubscribeUnfinishedSwaps(ctx)
},
unfinishedSwapNotification,
func(ntfn *swapserverrpc.ServerUnfinishedSwapNotification) []byte {
return ntfn.SwapHash
},
lntypes.Hash{0x02, 0x03}, lntypes.Hash{0x04, 0x05},
"did not receive first unfinished swap notification",
"second unfinished swap notification was dropped",
)
}
// TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber verifies
// that a full static-loop-in sweep subscriber channel does not block the global
// notification receive loop.
func TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber(
t *testing.T) {
t.Parallel()
assertQueuedSwapHashNotifications(
t,
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
ServerStaticLoopInSweepNotification {
return mgr.SubscribeStaticLoopInSweepRequests(ctx)
},
staticLoopInSweepNotification,
func(ntfn *swapserverrpc.ServerStaticLoopInSweepNotification) []byte {
return ntfn.SwapHash
},
lntypes.Hash{0x12, 0x13}, lntypes.Hash{0x14, 0x15},
"did not receive first sweep notification",
"second sweep notification was not queued",
)
}
// assertQueuedSwapHashNotifications checks queued delivery for swap hashes.
func assertQueuedSwapHashNotifications[T any](t *testing.T,
subscribe func(*Manager, context.Context) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse,
swapHash func(T) []byte, swapHashA, swapHashB lntypes.Hash,
firstFailureMsg, secondFailureMsg string) {
t.Helper()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := subscribe(mgr, subCtx)
mgr.handleNotification(t.Context(), notification(swapHashA))
done := make(chan struct{})
go func() {
mgr.handleNotification(t.Context(), notification(swapHashB))
close(done)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
select {
case received := <-subChan:
require.Equal(t, swapHashA[:], swapHash(received))
case <-time.After(time.Second):
t.Fatal(firstFailureMsg)
}
select {
case received := <-subChan:
require.Equal(t, swapHashB[:], swapHash(received))
case <-time.After(time.Second):
t.Fatal(secondFailureMsg)
}
}
// 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_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

@ -161,14 +161,25 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context,
// FinalizeDepositAction is the final action after a withdrawal. It signals to
// the manager that the deposit has been swept and the FSM can be removed.
func (f *FSM) FinalizeDepositAction(ctx context.Context,
func (f *FSM) FinalizeDepositAction(_ context.Context,
_ fsm.EventContext) fsm.EventType {
select {
case <-ctx.Done():
return fsm.OnError
outpoint := f.deposit.OutPoint
case f.finalizedDepositChan <- f.deposit.OutPoint:
return fsm.NoOp
}
// The finalization notification only tells the manager to remove the
// deposit from its active set. Send it asynchronously so a busy manager
// loop can't stall withdrawal confirmation while deposit locks are held.
go func() {
select {
case <-f.quitChan:
// The deposit is already in a final state. If shutdown wins
// this race, startup recovery will skip it instead of
// re-adding it to the active set.
return
case f.finalizedDepositChan <- outpoint:
}
}()
return fsm.NoOp
}

View file

@ -0,0 +1,135 @@
package deposit
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/fsm"
"github.com/stretchr/testify/require"
)
// TestFinalizeDepositActionDoesNotBlock ensures the final cleanup notification
// does not block the withdrawal completion path while the manager loop is busy.
func TestFinalizeDepositActionDoesNotBlock(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
outpoint := wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
}
depositFSM := &FSM{
deposit: &Deposit{
OutPoint: outpoint,
},
quitChan: make(chan struct{}),
finalizedDepositChan: make(chan wire.OutPoint),
}
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
}()
select {
case result := <-resultChan:
require.Equal(t, fsm.NoOp, result)
case <-time.After(100 * time.Millisecond):
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
}
select {
case gotOutpoint := <-depositFSM.finalizedDepositChan:
require.Equal(t, outpoint, gotOutpoint)
case <-time.After(time.Second):
t.Fatal("finalization cleanup notification was not delivered")
}
}
// TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup
// notification is tied to the FSM lifetime, not the caller's request context.
func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
quitChan := make(chan struct{})
defer close(quitChan)
outpoint := wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
}
depositFSM := &FSM{
deposit: &Deposit{
OutPoint: outpoint,
},
quitChan: quitChan,
finalizedDepositChan: make(chan wire.OutPoint),
}
resultChan := make(chan fsm.EventType, 1)
go func() {
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
}()
select {
case result := <-resultChan:
require.Equal(t, fsm.NoOp, result)
case <-time.After(100 * time.Millisecond):
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
}
cancel()
select {
case gotOutpoint := <-depositFSM.finalizedDepositChan:
require.Equal(t, outpoint, gotOutpoint)
case <-time.After(time.Second):
t.Fatal("finalization cleanup notification was dropped after " +
"request cancellation")
}
}
// TestFinalizeDepositActionIgnoresCanceledContext ensures the final cleanup
// notification is still queued even if the caller's context is already done.
func TestFinalizeDepositActionIgnoresCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
quitChan := make(chan struct{})
defer close(quitChan)
outpoint := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
}
depositFSM := &FSM{
deposit: &Deposit{
OutPoint: outpoint,
},
quitChan: quitChan,
finalizedDepositChan: make(chan wire.OutPoint),
}
result := depositFSM.FinalizeDepositAction(ctx, nil)
require.Equal(t, fsm.NoOp, result)
select {
case gotOutpoint := <-depositFSM.finalizedDepositChan:
require.Equal(t, outpoint, gotOutpoint)
case <-time.After(time.Second):
t.Fatal("finalization cleanup notification was dropped for " +
"an already-canceled request context")
}
}

View file

@ -29,6 +29,10 @@ func (r *ID) FromByteSlice(b []byte) error {
// Deposit bundles an utxo at a static address together with manager-relevant
// data.
//
// Lock order: if both Manager.mu and a Deposit lock are needed, acquire
// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a
// Deposit lock.
type Deposit struct {
sync.Mutex
@ -45,7 +49,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
@ -69,15 +74,22 @@ func (d *Deposit) IsInFinalState() bool {
d.Lock()
defer d.Unlock()
// Replaced is inactive from the deposit FSM's point of view. The manager may
// still revive the same record if lnd reports the exact outpoint again after
// a transient wallet-view miss.
return d.state == Expired || d.state == Withdrawn ||
d.state == LoopedIn || d.state == HtlcTimeoutSwept ||
d.state == ChannelPublished
d.state == ChannelPublished || d.state == Replaced
}
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
}

View file

@ -0,0 +1,15 @@
package deposit
import "testing"
// TestIsExpiredUnconfirmed checks that unconfirmed deposits don't start their
// expiry timer.
func TestIsExpiredUnconfirmed(t *testing.T) {
deposit := &Deposit{
ConfirmationHeight: 0,
}
if deposit.IsExpired(500, 100) {
t.Fatal("unconfirmed deposit should not be expired")
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
@ -41,10 +42,26 @@ 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")
// Replaced signals that a deposit disappeared from the wallet view and
// can no longer be spent.
//
// The concrete case we need to handle is mempool replacement: a user can
// receive to the static address, we persist that unconfirmed outpoint, and
// then the funding transaction can be replaced or otherwise evicted before
// confirmation. Once that happens lnd stops returning the old outpoint from
// ListUnspent, but our DB would otherwise keep presenting it as selectable.
// Replaced lets us retain the historic record while making it clear that the
// original outpoint is no longer a live deposit.
//
// This state is managed directly by the deposit manager rather than via
// DepositStatesV0 because it reflects wallet visibility changes such as
// mempool replacement or deep reorgs, not an FSM-driven spend path.
Replaced = fsm.StateType("Replaced")
// Withdrawing signals that the withdrawal transaction has been
// broadcast, awaiting sufficient confirmations.
Withdrawing = fsm.StateType("Withdrawing")
@ -92,8 +109,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
@ -160,12 +177,17 @@ type FSM struct {
blockNtfnChan chan uint32
// stopChan requests shutdown of the block notification loop.
stopChan chan struct{}
// quitChan stops after the FSM stops consuming blockNtfnChan.
quitChan chan struct{}
// finalizedDepositChan is used to signal that the deposit has been
// finalized and the FSM can be removed from the manager's memory.
finalizedDepositChan chan wire.OutPoint
stopOnce sync.Once
}
// NewFSM creates a new state machine that can action on all static address
@ -191,6 +213,7 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
params: params,
address: address,
blockNtfnChan: make(chan uint32),
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
finalizedDepositChan: finalizedDepositChan,
}
@ -226,6 +249,9 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
ctx, currentHeight,
)
case <-fsm.stopChan:
return
case <-ctx.Done():
return
}
@ -235,6 +261,17 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
return depoFsm, nil
}
// Stop requests shutdown of the FSM's block notification loop.
func (f *FSM) Stop() {
if f == nil || f.stopChan == nil {
return
}
f.stopOnce.Do(func() {
close(f.stopChan)
})
}
// handleBlockNotification inspects the current block height and sends the
// OnExpiry event to publish the expiry sweep transaction if the deposit timed
// out, or it republishes the expiry sweep transaction if it was not yet swept.

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
@ -33,6 +33,17 @@ const (
// PollInterval is the interval in which we poll for new deposits to our
// static address.
PollInterval = 10 * time.Second
// vanishedDepositThreshold is the number of consecutive wallet
// observations in which a Deposited outpoint must be missing before we
// mark it replaced.
//
// A single miss can happen during a transient wallet-view gap while lnd is
// processing a replacement or reorg. Requiring two misses keeps that narrow
// race recoverable without leaving vanished deposits selectable forever. At
// the default PollInterval, this means a vanished deposit can remain active
// for up to roughly 20 seconds.
vanishedDepositThreshold = 2
)
// ManagerConfig holds the configuration for the address manager.
@ -41,6 +52,10 @@ type ManagerConfig struct {
// address parameters.
AddressManager AddressManager
// ChainKit is used to query the best known chain tip when deriving
// confirmation heights from wallet UTXOs.
ChainKit lndclient.ChainKitClient
// Store is the database store that is used to store static address
// related records.
Store Store
@ -58,15 +73,27 @@ type ManagerConfig struct {
}
// Manager manages the address state machines.
//
// Lock order: if both Manager.mu and a Deposit lock are needed, acquire
// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a
// Deposit lock.
type Manager struct {
cfg *ManagerConfig
// mu guards access to the activeDeposits map.
mu sync.Mutex
// reconcileMu serializes deposit reconciliation so new deposits are
// discovered and retained exactly once per outpoint.
reconcileMu sync.Mutex
// activeDeposits contains all the active static address outputs.
activeDeposits map[wire.OutPoint]*FSM
// missingDeposits counts consecutive wallet observations in which a
// Deposited outpoint was missing from the wallet view.
missingDeposits map[wire.OutPoint]uint8
// deposits contain all the deposits that have ever been made to the
// static address. This field is used to store and recover deposits. It
// also serves as a basis for reconciliation of newly detected deposits
@ -77,6 +104,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.
@ -84,6 +114,7 @@ func NewManager(cfg *ManagerConfig) *Manager {
return &Manager{
cfg: cfg,
activeDeposits: make(map[wire.OutPoint]*FSM),
missingDeposits: make(map[wire.OutPoint]uint8),
deposits: make(map[wire.OutPoint]*Deposit),
finalizedDepositChan: make(chan wire.OutPoint),
}
@ -98,6 +129,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 {
@ -113,6 +157,13 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
log.Errorf("unable to reconcile deposits: %v", err)
}
// The startup height was consumed before recovered deposit FSMs existed.
// Replay it so already-expired recovered deposits can act immediately.
err = m.notifyActiveDeposits(ctx, startupHeight)
if err != nil {
return err
}
// Start the deposit notifier.
m.pollDeposits(ctx)
@ -123,32 +174,22 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
for {
select {
case height := <-newBlockChan:
// Inform all active deposits about a new block arrival.
m.mu.Lock()
activeDeposits := make([]*FSM, 0, len(m.activeDeposits))
for _, fsm := range m.activeDeposits {
activeDeposits = append(activeDeposits, fsm)
m.currentHeight.Store(uint32(height))
err := m.reconcileDeposits(ctx)
if err != nil {
log.Errorf("unable to reconcile deposits: %v", err)
}
m.mu.Unlock()
for _, fsm := range activeDeposits {
select {
case fsm.blockNtfnChan <- uint32(height):
case <-fsm.quitChan:
continue
case <-ctx.Done():
return ctx.Err()
}
err = m.notifyActiveDeposits(ctx, uint32(height))
if err != nil {
return err
}
case outpoint := <-m.finalizedDepositChan:
// If deposits notify us about their finalization, flush
// the finalized deposit from memory.
m.mu.Lock()
delete(m.activeDeposits, outpoint)
m.mu.Unlock()
m.removeActiveDeposit(outpoint)
case err = <-newBlockErrChan:
return err
@ -159,6 +200,33 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
}
}
// notifyActiveDeposits informs all active deposit FSMs about a new block
// height.
func (m *Manager) notifyActiveDeposits(ctx context.Context,
height uint32) error {
m.mu.Lock()
activeDeposits := make([]*FSM, 0, len(m.activeDeposits))
for _, fsm := range m.activeDeposits {
activeDeposits = append(activeDeposits, fsm)
}
m.mu.Unlock()
for _, fsm := range activeDeposits {
select {
case fsm.blockNtfnChan <- height:
case <-fsm.quitChan:
continue
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
// recoverDeposits recovers static address parameters, previous deposits and
// state machines from the database and starts the deposit notifier.
func (m *Manager) recoverDeposits(ctx context.Context) error {
@ -207,8 +275,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...")
@ -236,13 +306,36 @@ func (m *Manager) pollDeposits(ctx context.Context) {
// far. It picks the newly identified deposits and starts a state machine per
// deposit to track its progress.
func (m *Manager) reconcileDeposits(ctx context.Context) error {
m.reconcileMu.Lock()
defer m.reconcileMu.Unlock()
log.Tracef("Reconciling new deposits...")
utxos, err := m.cfg.AddressManager.ListUnspent(
ctx, MinConfs, MaxConfs,
)
utxos, bestHeight, err := m.listUnspentWithBestHeight(ctx)
if err != nil {
return fmt.Errorf("unable to list new deposits: %w", err)
return err
}
err = m.updateDepositConfirmations(ctx, utxos, bestHeight)
if err != nil {
return fmt.Errorf("unable to update deposit "+
"confirmations: %w", err)
}
// If the same outpoint reappeared after a transient wallet-view miss,
// reactivate the existing record before we consider it new or vanished.
err = m.reviveReappearedDeposits(ctx, utxos, bestHeight)
if err != nil {
return fmt.Errorf("unable to revive reappeared deposits: %w",
err)
}
// After handling reappearances, only still-missing outpoints contribute
// towards replacement detection.
err = m.invalidateVanishedDeposits(ctx, utxos)
if err != nil {
return fmt.Errorf("unable to invalidate vanished "+
"deposits: %w", err)
}
newDeposits := m.filterNewDeposits(utxos)
@ -252,7 +345,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, bestHeight)
if err != nil {
return fmt.Errorf("unable to retain new deposit: %w",
err)
@ -269,12 +362,71 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
return nil
}
// listUnspentWithBestHeight returns the wallet's current static-address UTXOs
// together with a stable chain tip height for any confirmed outputs.
func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
[]*lnwallet.Utxo, int32, error) {
utxos, err := m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs)
if err != nil {
return nil, 0, fmt.Errorf("unable to list new deposits: %w", err)
}
needsBestHeight := false
for _, utxo := range utxos {
if utxo.Confirmations > 0 {
needsBestHeight = true
break
}
}
if !needsBestHeight {
return utxos, 0, nil
}
if m.cfg.ChainKit == nil {
return nil, 0, errors.New("chain kit client required for " +
"confirmed deposits")
}
const maxAttempts = 3
for range maxAttempts {
_, beforeHeight, err := m.cfg.ChainKit.GetBestBlock(ctx)
if err != nil {
return nil, 0, fmt.Errorf("unable to get best block "+
"before listing deposits: %w", err)
}
utxos, err = m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs)
if err != nil {
return nil, 0, fmt.Errorf("unable to list new deposits: %w",
err)
}
_, afterHeight, err := m.cfg.ChainKit.GetBestBlock(ctx)
if err != nil {
return nil, 0, fmt.Errorf("unable to get best block "+
"after listing deposits: %w", err)
}
if beforeHeight == afterHeight {
m.currentHeight.Store(uint32(afterHeight))
return utxos, afterHeight, nil
}
}
return nil, 0, errors.New("unable to get stable best block while " +
"listing deposits")
}
// 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, bestHeight int32) (*Deposit, error) {
blockHeight, err := m.getBlockHeight(ctx, utxo)
confirmationHeight, err := confirmationHeightForUtxo(
bestHeight, utxo,
)
if err != nil {
return nil, err
}
@ -302,7 +454,7 @@ func (m *Manager) createNewDeposit(ctx context.Context,
state: Deposited,
OutPoint: utxo.OutPoint,
Value: utxo.Value,
ConfirmationHeight: int64(blockHeight),
ConfirmationHeight: confirmationHeight,
TimeOutSweepPkScript: timeoutSweepPkScript,
}
@ -318,37 +470,242 @@ 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 a stable best-known chain tip. Unconfirmed UTXOs return 0.
func confirmationHeightForUtxo(bestHeight int32,
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 bestHeight <= 0 {
return 0, fmt.Errorf("invalid best height %d", bestHeight)
}
firstConfirmationHeight := int64(bestHeight) - utxo.Confirmations + 1
if firstConfirmationHeight <= 0 {
return 0, fmt.Errorf("invalid confirmation height %d for %v "+
"with best height %d and %d confirmations",
firstConfirmationHeight, utxo.OutPoint, bestHeight,
utxo.Confirmations)
}
return firstConfirmationHeight, nil
}
// updateDepositConfirmations backfills first confirmation heights for deposits
// that were previously detected unconfirmed.
func (m *Manager) updateDepositConfirmations(ctx context.Context,
utxos []*lnwallet.Utxo, bestHeight int32) error {
for _, utxo := range utxos {
if utxo.Confirmations <= 0 {
continue
}
m.mu.Lock()
deposit, ok := m.deposits[utxo.OutPoint]
m.mu.Unlock()
if !ok {
continue
}
deposit.Lock()
if deposit.ConfirmationHeight > 0 {
deposit.Unlock()
continue
}
deposit.Unlock()
confirmationHeight, err := confirmationHeightForUtxo(
bestHeight, utxo,
)
if err != nil {
return 0, err
if err != nil {
return err
}
deposit.Lock()
if deposit.ConfirmationHeight > 0 {
deposit.Unlock()
continue
}
previousConfirmationHeight := deposit.ConfirmationHeight
deposit.ConfirmationHeight = confirmationHeight
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
if err != nil {
deposit.ConfirmationHeight = previousConfirmationHeight
deposit.Unlock()
return err
}
deposit.Unlock()
}
select {
case tx := <-notifChan:
return tx.BlockHeight, nil
return nil
}
case err := <-errChan:
return 0, err
// reviveReappearedDeposits reactivates deposits that were previously marked as
// replaced if the exact same outpoint reappears in the wallet view.
//
// This is the inverse of invalidateVanishedDeposits: it lets us
// recover from a transient ListUnspent gap without inventing a second record
// for the same outpoint.
func (m *Manager) reviveReappearedDeposits(ctx context.Context,
utxos []*lnwallet.Utxo, bestHeight int32) error {
case <-ctx.Done():
return 0, ctx.Err()
type reviveCandidate struct {
deposit *Deposit
utxo *lnwallet.Utxo
}
var candidates []reviveCandidate
m.mu.Lock()
for _, utxo := range utxos {
delete(m.missingDeposits, utxo.OutPoint)
deposit, ok := m.deposits[utxo.OutPoint]
if !ok {
continue
}
if _, active := m.activeDeposits[utxo.OutPoint]; active {
continue
}
deposit.Lock()
isReplaced := deposit.IsInStateNoLock(Replaced)
deposit.Unlock()
if !isReplaced {
continue
}
candidates = append(candidates, reviveCandidate{
deposit: deposit,
utxo: utxo,
})
}
m.mu.Unlock()
for _, candidate := range candidates {
confirmationHeight, err := confirmationHeightForUtxo(
bestHeight, candidate.utxo,
)
if err != nil {
return err
}
deposit := candidate.deposit
deposit.Lock()
if !deposit.IsInStateNoLock(Replaced) {
deposit.Unlock()
continue
}
previousState := deposit.state
previousConfirmationHeight := deposit.ConfirmationHeight
deposit.ConfirmationHeight = confirmationHeight
deposit.SetStateNoLock(Deposited)
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
if err != nil {
deposit.ConfirmationHeight = previousConfirmationHeight
deposit.SetStateNoLock(previousState)
deposit.Unlock()
return err
}
deposit.Unlock()
log.Infof("Reactivated deposit %v after it reappeared in "+
"wallet view", deposit.OutPoint)
err = m.startDepositFsm(ctx, deposit)
if err != nil {
return err
}
}
return nil
}
// invalidateVanishedDeposits marks Deposited outputs as replaced once lnd no
// longer reports the outpoint in multiple consecutive wallet observations.
//
// This closes the gap between wallet state and our DB state when a persisted
// deposit later disappears from the wallet view, for example because an
// unconfirmed funding transaction was replaced or because a previously
// confirmed transaction was evicted by a deep reorg. We only invalidate
// deposits that are still in the plain Deposited state.
//
// That keeps the scope narrow: in-flight states like LoopingIn already have
// their own recovery/error handling.
func (m *Manager) invalidateVanishedDeposits(ctx context.Context,
utxos []*lnwallet.Utxo) error {
currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos))
for _, utxo := range utxos {
currentUtxos[utxo.OutPoint] = struct{}{}
}
m.mu.Lock()
candidates := make([]*Deposit, 0, len(m.deposits))
for outpoint, deposit := range m.deposits {
if _, ok := currentUtxos[outpoint]; ok {
delete(m.missingDeposits, outpoint)
continue
}
deposit.Lock()
isVanishedDeposit := deposit.IsInStateNoLock(Deposited)
deposit.Unlock()
if !isVanishedDeposit {
delete(m.missingDeposits, outpoint)
continue
}
m.missingDeposits[outpoint]++
if m.missingDeposits[outpoint] < vanishedDepositThreshold {
log.Debugf("Waiting for another wallet observation before "+
"marking deposit %v replaced", outpoint)
continue
}
delete(m.missingDeposits, outpoint)
candidates = append(candidates, deposit)
}
m.mu.Unlock()
for _, deposit := range candidates {
deposit.Lock()
if !deposit.IsInStateNoLock(Deposited) {
deposit.Unlock()
continue
}
// Persist the replacement marker before removing the deposit from the
// active set so restarted clients and RPC consumers see the same outcome.
previousState := deposit.state
deposit.SetStateNoLock(Replaced)
err := m.cfg.Store.UpdateDeposit(ctx, deposit)
if err != nil {
deposit.SetStateNoLock(previousState)
deposit.Unlock()
return err
}
deposit.Unlock()
m.removeActiveDeposit(deposit.OutPoint)
log.Infof("Marked vanished deposit %v as replaced",
deposit.OutPoint)
}
return nil
}
// filterNewDeposits filters the given utxos for new deposits that we haven't
@ -537,6 +894,20 @@ func unlockDeposits(deposits []*Deposit) {
}
}
// removeActiveDeposit removes and stops the FSM for an active outpoint.
func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) {
m.mu.Lock()
fsm, ok := m.activeDeposits[outpoint]
if ok {
delete(m.activeDeposits, outpoint)
}
m.mu.Unlock()
if ok {
fsm.Stop()
}
}
// GetAllDeposits returns all active deposits.
func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) {
return m.cfg.Store.AllDeposits(ctx)

View file

@ -0,0 +1,40 @@
package deposit
import (
"testing"
"github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require"
)
// TestConfirmationHeightForUtxo verifies first-confirmation height lookup for
// wallet UTXOs.
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 uses best height arithmetic", func(t *testing.T) {
height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{
Confirmations: 1,
OutPoint: wire.OutPoint{
Index: 1,
},
})
require.NoError(t, err)
require.EqualValues(t, 101, height)
})
t.Run("rejects impossible height", func(t *testing.T) {
_, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{
Confirmations: 4,
OutPoint: wire.OutPoint{
Index: 3,
},
})
require.ErrorContains(t, err, "invalid confirmation height")
})
}

View file

@ -0,0 +1,436 @@
package deposit
import (
"context"
"errors"
"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/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"
)
// expectStableBestBlock configures two stable best-block lookups.
func expectStableBestBlock(mockChainKit *MockChainKit, height int32) {
mockChainKit.On(
"GetBestBlock", mock.Anything,
).Return(chainhash.Hash{}, height, nil).Twice()
}
// 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++
require.ErrorContains(t, err, "unable to start new deposit FSM")
}
require.Equal(t, 1, errCount)
}
// TestReconcileConfirmedDepositUsesBestBlockHeight verifies confirmation
// heights are derived from a stable chain tip.
func TestReconcileConfirmedDepositUsesBestBlockHeight(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"))
mockChainKit := new(MockChainKit)
expectStableBestBlock(mockChainKit, 100)
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,
ChainKit: mockChainKit,
Store: mockStore,
WalletKit: mockLnd.WalletKit,
Signer: mockLnd.Signer,
})
err := manager.reconcileDeposits(ctx)
require.ErrorContains(t, err, "unable to start new deposit FSM")
}
// TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit verifies that a
// single missing ListUnspent observation is reversible, but repeated misses
// still mark the deposit as replaced.
func TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit(t *testing.T) {
ctx := context.Background()
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)
mockStore := new(mockStore)
var updateCalls atomic.Int32
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updateCalls.Add(1)
updatedDeposit := args.Get(1).(*Deposit)
require.True(t, updatedDeposit.IsInStateNoLock(Replaced))
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
stopChan: make(chan struct{}),
quitChan: make(chan struct{}),
}
go func() {
<-fsm.stopChan
close(fsm.quitChan)
}()
manager.activeDeposits[outpoint] = fsm
// The first miss only increments the consecutive-miss counter.
require.NoError(t, manager.reconcileDeposits(ctx))
require.EqualValues(t, 0, updateCalls.Load())
require.Equal(t, Deposited, deposit.GetState())
require.Len(t, manager.activeDeposits, 1)
// The second consecutive miss is strong enough evidence to finalize the
// record as replaced.
require.NoError(t, manager.reconcileDeposits(ctx))
require.EqualValues(t, 1, updateCalls.Load())
require.Equal(t, Replaced, deposit.GetState())
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after deposit was replaced")
}
}
// TestReconcileDepositsInvalidatesVanishedConfirmedDeposit verifies that a
// previously confirmed deposit is also marked replaced if it vanishes from the
// wallet view for multiple consecutive observations.
func TestReconcileDepositsInvalidatesVanishedConfirmedDeposit(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)
mockStore := new(mockStore)
var updateCalls atomic.Int32
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updateCalls.Add(1)
updatedDeposit := args.Get(1).(*Deposit)
require.True(t, updatedDeposit.IsInStateNoLock(Replaced))
require.EqualValues(
t, 123, updatedDeposit.ConfirmationHeight,
)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
fsm := &FSM{
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.EqualValues(t, 0, updateCalls.Load())
require.Equal(t, Deposited, deposit.GetState())
require.Len(t, manager.activeDeposits, 1)
require.NoError(t, manager.reconcileDeposits(ctx))
require.EqualValues(t, 1, updateCalls.Load())
require.Equal(t, Replaced, deposit.GetState())
require.Empty(t, manager.activeDeposits)
select {
case <-fsm.quitChan:
case <-time.After(time.Second):
t.Fatal("fsm did not stop after confirmed deposit was replaced")
}
}
// TestReconcileDepositsReactivatesReappearedReplacedDeposit verifies that the
// same outpoint can be revived if lnd reports it again after being marked
// replaced.
func TestReconcileDepositsReactivatesReappearedReplacedDeposit(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(Replaced)
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)
mockStore.On(
"UpdateDeposit", mock.Anything, mock.Anything,
).Return(nil).Run(func(args mock.Arguments) {
updatedDeposit := args.Get(1).(*Deposit)
require.True(t, updatedDeposit.IsInStateNoLock(Deposited))
require.Zero(t, updatedDeposit.ConfirmationHeight)
})
manager := NewManager(&ManagerConfig{
AddressManager: mockAddressManager,
Store: mockStore,
})
manager.deposits[outpoint] = deposit
// Reconciliation should revive 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)
}
// 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
manager.missingDeposits[oldOutpoint] = 1
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])
require.Empty(t, manager.missingDeposits)
mockStore.AssertNotCalled(
t, "UpdateDeposit", mock.Anything, mock.Anything,
)
}

View file

@ -215,6 +215,49 @@ func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context,
args.Get(1).(chan error), args.Error(2)
}
type MockChainKit struct {
mock.Mock
}
// RawClientWithMacAuth implements lndclient.ChainKitClient for tests.
func (m *MockChainKit) RawClientWithMacAuth(
ctx context.Context) (context.Context, time.Duration,
chainrpc.ChainKitClient) {
return ctx, 0, nil
}
// GetBlock implements lndclient.ChainKitClient for tests.
func (m *MockChainKit) GetBlock(context.Context, chainhash.Hash) (
*wire.MsgBlock, error) {
panic("unexpected GetBlock call")
}
// GetBlockHeader implements lndclient.ChainKitClient for tests.
func (m *MockChainKit) GetBlockHeader(context.Context, chainhash.Hash) (
*wire.BlockHeader, error) {
panic("unexpected GetBlockHeader call")
}
// GetBestBlock returns the configured best-block mock response.
func (m *MockChainKit) GetBestBlock(ctx context.Context) (
chainhash.Hash, int32, error) {
args := m.Called(ctx)
return args.Get(0).(chainhash.Hash), args.Get(1).(int32),
args.Error(2)
}
// GetBlockHash implements lndclient.ChainKitClient for tests.
func (m *MockChainKit) GetBlockHash(context.Context, int64) (
chainhash.Hash, error) {
panic("unexpected GetBlockHash call")
}
// TestManager checks that the manager processes the right channel notifications
// while a deposit is expiring.
func TestManager(t *testing.T) {
@ -233,6 +276,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:
@ -303,6 +350,61 @@ 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")
}
}
// ManagerTestContext is a helper struct that contains all the necessary
// components to test the reservation manager.
type ManagerTestContext struct {

View file

@ -1,6 +1,7 @@
package loopin
import (
"bytes"
"context"
"crypto/rand"
"errors"
@ -12,6 +13,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/chain"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
@ -36,6 +38,8 @@ const (
defaultConfTarget = 3
DefaultPaymentTimeoutSeconds = 60
defaultInvoiceCleanupTimeout = 5 * time.Second
)
var (
@ -57,6 +61,24 @@ var (
func (f *FSM) InitHtlcAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
var event fsm.EventType
invoiceNeedsCleanup := false
defer func() {
// If we created the private invoice but failed before persisting the
// swap, cancel it so retries do not accumulate orphan invoices.
if !invoiceNeedsCleanup || event != fsm.OnError {
return
}
f.cancelSwapInvoice(ctx)
}()
returnError := func(err error) fsm.EventType {
event = f.HandleError(err)
return event
}
// Lock the deposits and transition them to the LoopingIn state.
err := f.cfg.DepositManager.TransitionDeposits(
ctx, f.loopIn.Deposits, deposit.OnLoopInInitiated,
@ -65,7 +87,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
if err != nil {
err = fmt.Errorf("unable to loop-in deposits: %w", err)
return f.HandleError(err)
return returnError(err)
}
// Calculate the swap invoice amount. The server needs to pay us the
@ -88,7 +110,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
err = fmt.Errorf("unable to create random swap preimage: %w",
err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.SwapPreimage = swapPreimage
f.loopIn.SwapHash = swapPreimage.Hash()
@ -100,7 +122,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
if err != nil {
err = fmt.Errorf("unable to derive client htlc key: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.ClientPubkey = keyDesc.PubKey
f.loopIn.HtlcKeyLocator = keyDesc.KeyLocator
@ -119,10 +141,14 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
if err != nil {
err = fmt.Errorf("unable to create swap invoice: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.SwapInvoice = swapInvoice
// From here until CreateLoopIn succeeds, any error path would otherwise
// leave behind a live invoice with no persisted swap to recover it.
invoiceNeedsCleanup = true
f.loopIn.ProtocolVersion = version.AddressProtocolVersion(
version.CurrentRPCProtocolVersion(),
)
@ -149,7 +175,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
err = fmt.Errorf("unable to initiate the loop-in with the "+
"server: %w", err)
return f.HandleError(err)
return returnError(err)
}
// Pushing empty sigs signals the server that we abandoned the swap
@ -171,7 +197,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
pushEmptySigs()
err = fmt.Errorf("unable to parse server pubkey: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.ServerPubkey = serverPubkey
@ -185,7 +211,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
err = fmt.Errorf("server response parameters are outside "+
"our allowed range: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.HtlcCltvExpiry = loopInResp.HtlcExpiry
@ -194,7 +220,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
pushEmptySigs()
err = fmt.Errorf("unable to convert server nonces: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.htlcServerNoncesHighFee, err = toNonces(
loopInResp.HighFeeHtlcInfo.Nonces,
@ -202,7 +228,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
if err != nil {
pushEmptySigs()
return f.HandleError(err)
return returnError(err)
}
f.htlcServerNoncesExtremelyHighFee, err = toNonces(
loopInResp.ExtremeFeeHtlcInfo.Nonces,
@ -210,7 +236,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
if err != nil {
pushEmptySigs()
return f.HandleError(err)
return returnError(err)
}
// We need to defend against the server setting high fees for the htlc
@ -232,7 +258,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
log.Errorf("server htlc tx fee is higher than the configured "+
"allowed maximum: %v > %v", fee, maxHtlcTxFee)
return f.HandleError(ErrFeeTooHigh)
return returnError(ErrFeeTooHigh)
}
f.loopIn.HtlcTxFeeRate = feeRate
@ -246,7 +272,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
"configured allowed maximum: %v > %v", fee,
maxHtlcTxBackupFee)
return f.HandleError(ErrFeeTooHigh)
return returnError(ErrFeeTooHigh)
}
f.loopIn.HtlcTxHighFeeRate = highFeeRate
@ -262,7 +288,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
"configured allowed maximum: %v > %v", fee,
maxHtlcTxBackupFee)
return f.HandleError(ErrFeeTooHigh)
return returnError(ErrFeeTooHigh)
}
f.loopIn.HtlcTxExtremelyHighFeeRate = extremelyHighFeeRate
@ -276,7 +302,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
err = fmt.Errorf("unable to derive htlc timeout sweep "+
"address: %w", err)
return f.HandleError(err)
return returnError(err)
}
f.loopIn.HtlcTimeoutSweepAddress = sweepAddress
@ -286,10 +312,148 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
pushEmptySigs()
err = fmt.Errorf("unable to store loop-in in db: %w", err)
return f.HandleError(err)
return returnError(err)
}
return OnHtlcInitiated
// Once the swap is stored, restart/recovery code owns invoice lifecycle.
invoiceNeedsCleanup = false
event = OnHtlcInitiated
return event
}
// cancelSwapInvoice best-effort cancels the current swap invoice using a
// detached timeout-limited context so cleanup still runs even if the caller's
// context is already done.
func (f *FSM) cancelSwapInvoice(ctx context.Context) {
cleanupCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx), defaultInvoiceCleanupTimeout,
)
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)
}
}
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
// reports whether the update produced a terminal event.
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
fsm.EventType, bool) {
switch update.State {
case invoices.ContractOpen:
return fsm.NoOp, false
case invoices.ContractAccepted:
return fsm.NoOp, false
case invoices.ContractSettled:
f.Debugf("received off-chain payment update %v", update.State)
return OnPaymentReceived, true
case invoices.ContractCanceled:
// If the invoice was canceled we only log here since we still need
// to monitor until the htlc timed out.
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
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
}
}
// 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
}
d.Lock()
outpoint := d.OutPoint.String()
confirmationHeight := d.ConfirmationHeight
d.Unlock()
if _, ok := outpoints[outpoint]; !ok {
continue
}
confirmations[outpoint] = confirmationHeight
}
return confirmations
}
// 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
}
// 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
}
const includeMempool = true
for _, outpointStr := range f.loopIn.DepositOutpoints {
outpoint, err := wire.NewOutPointFromString(outpointStr)
if err != nil {
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
outpointStr, err)
}
txOut, err := f.cfg.TxOutChecker.GetTxOut(
ctx, *outpoint, includeMempool,
)
if err != nil {
return false, fmt.Errorf("unable to get txout %v: %w",
outpoint, err)
}
if txOut == nil {
return true, nil
}
}
return false, nil
}
// SignHtlcTxAction is called if the htlc was initialized and the server
@ -300,6 +464,18 @@ 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)
f.cancelSwapInvoice(ctx)
return f.HandleError(err)
}
f.loopIn.AddressParams, err =
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
@ -511,7 +687,28 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return f.HandleError(err)
}
var (
riskAcceptedChan <-chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification
riskRejectedChan <-chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification
cancelRiskNotificationSubscriptions = func() {}
)
if f.cfg.NotificationManager != nil {
notificationCtx, cancel := context.WithCancel(ctx)
cancelRiskNotificationSubscriptions = cancel
riskAcceptedChan = f.cfg.NotificationManager.
SubscribeStaticLoopInRiskAccepted(
notificationCtx, f.loopIn.SwapHash,
)
riskRejectedChan = f.cfg.NotificationManager.
SubscribeStaticLoopInRiskRejected(
notificationCtx, f.loopIn.SwapHash,
)
}
defer cancelRiskNotificationSubscriptions()
htlcConfirmed := false
depositsUnlocked := false
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
if err != nil {
@ -521,30 +718,40 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return f.HandleError(err)
}
// 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 set the timeout to 0 to cancel the
// invoice and unlock the deposits immediately.
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
// 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
)
defer func() {
if deadlineTimer != nil {
deadlineTimer.Stop()
}
}()
// 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
startPaymentDeadline := func(reason string, startedAt time.Time) {
if deadlineStarted || invoice.State == invoices.ContractCanceled {
return
}
deadlineChan = time.NewTimer(timeout).C
} else {
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
}
if invoice.State == invoices.ContractCanceled {
// If the invoice was canceled previously we end our
// subscription to invoice updates.
cancelInvoiceSubscription()
@ -557,11 +764,87 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
// Cancel the lndclient invoice subscription.
cancelInvoiceSubscription()
err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash)
if err != nil {
f.Warnf("unable to cancel invoice "+
"for swap hash: %v", err)
// Reuse the same helper as InitHtlcAction so timeout cleanup follows
// the same detached-context path as early-init cleanup.
f.cancelSwapInvoice(ctx)
}
riskDecisionTime := func(decision ConfirmationRiskDecision) time.Time {
now := time.Now()
if f.cfg.Store == nil {
return now
}
storedLoopIn, err := f.cfg.Store.GetLoopInByHash(
ctx, f.loopIn.SwapHash,
)
if err != nil {
f.Warnf("unable to reload persisted risk decision for "+
"swap %v: %v", f.loopIn.SwapHash, err)
return now
}
if storedLoopIn == nil {
return now
}
hasPersistedDecision :=
storedLoopIn.ConfirmationRiskDecision == decision &&
!storedLoopIn.ConfirmationRiskDecisionTime.IsZero()
if !hasPersistedDecision {
err = f.cfg.Store.RecordStaticAddressRiskDecision(
ctx, f.loopIn.SwapHash, decision,
)
if err != nil {
f.Warnf("unable to persist replayed risk "+
"decision for swap %v: %v",
f.loopIn.SwapHash, err)
return now
}
storedLoopIn, err = f.cfg.Store.GetLoopInByHash(
ctx, f.loopIn.SwapHash,
)
if err != nil {
f.Warnf("unable to reload persisted risk "+
"decision for swap %v: %v",
f.loopIn.SwapHash, err)
return now
}
if storedLoopIn == nil ||
storedLoopIn.ConfirmationRiskDecision != decision ||
storedLoopIn.ConfirmationRiskDecisionTime.IsZero() {
return now
}
}
f.loopIn.ConfirmationRiskDecision =
storedLoopIn.ConfirmationRiskDecision
f.loopIn.ConfirmationRiskDecisionTime =
storedLoopIn.ConfirmationRiskDecisionTime
return storedLoopIn.ConfirmationRiskDecisionTime
}
switch f.loopIn.ConfirmationRiskDecision {
case ConfirmationRiskDecisionAccepted:
startPaymentDeadline(
"recovered risk accepted notification",
f.loopIn.ConfirmationRiskDecisionTime,
)
case ConfirmationRiskDecisionRejected:
cancelInvoiceSubscription()
f.cancelSwapInvoice(ctx)
return f.HandleError(errors.New(
"server rejected confirmation risk wait",
))
}
for {
@ -603,19 +886,87 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
}
case <-deadlineChan:
deadlineChan = nil
// 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()
event := f.UnlockDepositsAction(ctx, nil)
if event != fsm.OnError {
err := f.unlockDeposits(ctx)
if err != nil {
f.Errorf("unable to unlock deposits after " +
"payment deadline")
continue
}
depositsUnlocked = true
case riskAccepted, ok := <-riskAcceptedChan:
if !ok {
riskAcceptedChan = nil
continue
}
if !bytes.Equal(
riskAccepted.SwapHash, f.loopIn.SwapHash[:],
) {
continue
}
startedAt := riskDecisionTime(
ConfirmationRiskDecisionAccepted,
)
f.loopIn.ConfirmationRiskDecision =
ConfirmationRiskDecisionAccepted
f.loopIn.ConfirmationRiskDecisionTime = startedAt
startPaymentDeadline(
"risk accepted notification",
f.loopIn.ConfirmationRiskDecisionTime,
)
case riskRejected, ok := <-riskRejectedChan:
if !ok {
riskRejectedChan = nil
continue
}
if !bytes.Equal(
riskRejected.SwapHash, f.loopIn.SwapHash[:],
) {
continue
}
cancelInvoiceSubscription()
f.cancelSwapInvoice(ctx)
decisionTime := riskDecisionTime(
ConfirmationRiskDecisionRejected,
)
f.loopIn.ConfirmationRiskDecision =
ConfirmationRiskDecisionRejected
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
return f.HandleError(errors.New(
"server rejected confirmation risk wait",
))
case currentHeight := <-blockChan:
depositConfirmationHeights :=
selectedDepositConfirmationHeights(f.loopIn)
if legacyMinConfsReached(
f.loopIn.DepositOutpoints,
depositConfirmationHeights, currentHeight,
) {
startPaymentDeadline(
"legacy confirmation fallback",
time.Time{},
)
}
// 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
@ -641,13 +992,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
if !htlcConfirmed {
f.Infof("swap timed out, htlc not confirmed")
// 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.
if !depositsUnlocked {
err = f.unlockDeposits(ctx)
if err != nil {
return f.HandleError(err)
}
}
return OnSwapTimedOut
}
@ -671,32 +1022,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
return f.HandleError(err)
case update := <-invoiceUpdateChan:
switch update.State {
case invoices.ContractOpen:
case invoices.ContractAccepted:
case invoices.ContractSettled:
f.Debugf("received off-chain payment update "+
"%v", update.State)
return OnPaymentReceived
case invoices.ContractCanceled:
// If the invoice was canceled we only log here
// since we still need to monitor until the htlc
// timed out.
log.Warnf("invoice for swap hash %v canceled",
f.loopIn.SwapHash)
default:
err = fmt.Errorf("unexpected invoice state %v "+
"for swap hash %v canceled",
update.State, f.loopIn.SwapHash)
return f.HandleError(err)
case update, ok := <-invoiceUpdateChan:
if !ok {
invoiceUpdateChan = nil
continue
}
if event, done := f.handleInvoiceUpdate(update); done {
return event
}
case err, ok := <-invoiceErrChan:
if !ok {
invoiceErrChan = nil
continue
}
case err = <-invoiceErrChan:
f.Errorf("invoice subscription error: %v", err)
case <-ctx.Done():
@ -824,9 +1165,7 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context,
func (f *FSM) UnlockDepositsAction(ctx context.Context,
_ fsm.EventContext) fsm.EventType {
err := f.cfg.DepositManager.TransitionDeposits(
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
)
err := f.unlockDeposits(ctx)
if err != nil {
err = fmt.Errorf("unable to unlock deposits: %w", err)
@ -836,6 +1175,13 @@ func (f *FSM) UnlockDepositsAction(ctx context.Context,
return fsm.OnError
}
// unlockDeposits resets this loop-in's deposits so they can be selected again.
func (f *FSM) unlockDeposits(ctx context.Context) error {
return f.cfg.DepositManager.TransitionDeposits(
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
)
}
// createAndPublishHtlcTimeoutSweepTx creates and publishes the htlc timeout
// sweep transaction.
func (f *FSM) createAndPublishHtlcTimeoutSweepTx(ctx context.Context) error {

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import (
"context"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/fsm"
"github.com/lightninglabs/loop/staticaddr/deposit"
@ -87,6 +88,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)
@ -105,10 +111,34 @@ type QuoteGetter interface {
numDeposits uint32, fast bool) (*loop.LoopInQuote, error)
}
// TxOutChecker checks whether an outpoint is still available in the chain
// backend's UTXO view.
type TxOutChecker interface {
// GetTxOut returns nil if the outpoint is unavailable or spent. The
// includeMempool flag must be passed through to the underlying chain
// backend.
GetTxOut(ctx context.Context, outpoint wire.OutPoint,
includeMempool bool) (*wire.TxOut, error)
}
type NotificationManager interface {
// SubscribeStaticLoopInSweepRequests subscribes to the static loop in
// sweep requests. These are sent by the server to the client to request
// 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
@ -93,8 +110,6 @@ type StaticAddressLoopIn struct {
// The outpoints in the format txid:vout that are part of the loop-in
// swap.
// TODO(hieblmi): Replace this with a getter method that fetches the
// outpoints from the deposits.
DepositOutpoints []string
// SelectedAmount is the amount that the user selected for the swap. If
@ -109,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
@ -469,12 +493,28 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount {
// RemainingPaymentTimeSeconds returns the remaining time in seconds until the
// payment timeout is reached. The remaining time is calculated from the
// initiation time of the swap. If more than the swaps configured payment
// initiation time of the swap. If more than the swap's configured payment
// timeout has passed, the remaining time will be negative.
func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 {
elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds()
return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation)
return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation)
}
// PaymentTimeoutDuration returns the configured payment timeout duration,
// falling back to the default if the swap predates the persisted timeout field.
func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration {
return time.Duration(l.paymentTimeoutSeconds()) * time.Second
}
// paymentTimeoutSeconds returns the configured timeout in seconds.
func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 {
timeoutSeconds := int64(l.PaymentTimeoutSeconds)
if timeoutSeconds == 0 {
timeoutSeconds = int64(DefaultPaymentTimeoutSeconds)
}
return timeoutSeconds
}
// Outpoints returns the wire outpoints of the deposits.

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"math"
"slices"
"sort"
"sync/atomic"
@ -78,6 +79,10 @@ type Config struct {
// blocks.
ChainNotifier lndclient.ChainNotifierClient
// TxOutChecker checks whether selected deposit outpoints are still
// available before we sign an HTLC transaction for them.
TxOutChecker TxOutChecker
// Signer is the signer client that is used to sign transactions.
Signer lndclient.SignerClient
@ -752,8 +757,10 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
}
swap := &StaticAddressLoopIn{
SelectedAmount: req.SelectedAmount,
DepositOutpoints: selectedOutpoints,
SelectedAmount: req.SelectedAmount,
DepositOutpoints: append(
[]string(nil), selectedOutpoints...,
),
Deposits: selectedDeposits,
Label: req.Label,
Initiator: req.Initiator,
@ -843,11 +850,11 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn,
return swaps, nil
}
// 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) {
@ -868,14 +875,25 @@ func SelectDeposits(targetAmount btcutil.Amount,
deposits = append(deposits, d)
}
// Sort the deposits by amount in descending order, then by
// blocks-until-expiry in ascending order.
// Sort confirmed deposits ahead of unconfirmed ones so auto-selection
// prefers deposits the server can accept immediately. Within each group
// we prefer larger deposits, then earlier expiries.
sort.Slice(deposits, func(i, j int) bool {
iConfirmed := deposits[i].ConfirmationHeight > 0
jConfirmed := deposits[j].ConfirmationHeight > 0
if iConfirmed != jConfirmed {
return iConfirmed
}
if deposits[i].Value == deposits[j].Value {
iExp := uint32(deposits[i].ConfirmationHeight) +
csvExpiry - blockHeight
jExp := uint32(deposits[j].ConfirmationHeight) +
csvExpiry - blockHeight
iExp := blocksUntilDepositExpiry(
uint32(deposits[i].ConfirmationHeight),
blockHeight, csvExpiry,
)
jExp := blocksUntilDepositExpiry(
uint32(deposits[j].ConfirmationHeight),
blockHeight, csvExpiry,
)
return iExp < jExp
}
@ -907,20 +925,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

@ -77,6 +77,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},
@ -220,6 +241,12 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
}
// 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.
@ -355,6 +382,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

@ -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)
@ -203,7 +211,7 @@ func (s *SqlStore) GetStaticAddressLoopInSwapsByStates(ctx context.Context,
}
func toJointStringStates(states []fsm.StateType) string {
return "{" + strings.Join(toStrings(states), ",") + "}"
return strings.Join(toStrings(states), ",")
}
func toStrings(states []fsm.StateType) []string {
@ -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 {
@ -507,9 +552,12 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
}
}
depositOutpoints := strings.Split(
swap.DepositOutpoints, OutpointSeparator,
)
var depositOutpoints []string
if swap.DepositOutpoints != "" {
depositOutpoints = strings.Split(
swap.DepositOutpoints, OutpointSeparator,
)
}
timeoutAddressString := swap.HtlcTimeoutSweepAddress
var timeoutAddress btcutil.Address
@ -580,6 +628,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,
),
@ -587,6 +638,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

@ -42,7 +42,8 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
loopingDepositID := newID()
loopedInDepositID := newID()
d1, d2 := &deposit.Deposit{
failedDepositID := newID()
d1, d2, d3 := &deposit.Deposit{
ID: loopingDepositID,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
@ -63,29 +64,48 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
TimeOutSweepPkScript: []byte{
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d,
},
},
&deposit.Deposit{
ID: failedDepositID,
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{0x3a, 0x2b, 0x3c, 0x4e},
Index: 2,
},
Value: btcutil.Amount(300_000),
TimeOutSweepPkScript: []byte{
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4f,
},
}
err := depositStore.CreateDeposit(ctxb, d1)
require.NoError(t, err)
err = depositStore.CreateDeposit(ctxb, d2)
require.NoError(t, err)
err = depositStore.CreateDeposit(ctxb, d3)
require.NoError(t, err)
// Add two updates per deposit, expect the last to be retrieved.
d1.SetState(deposit.Deposited)
d2.SetState(deposit.Deposited)
d3.SetState(deposit.Deposited)
err = depositStore.UpdateDeposit(ctxb, d1)
require.NoError(t, err)
err = depositStore.UpdateDeposit(ctxb, d2)
require.NoError(t, err)
err = depositStore.UpdateDeposit(ctxb, d3)
require.NoError(t, err)
d1.SetState(deposit.LoopingIn)
d2.SetState(deposit.LoopedIn)
d3.SetState(deposit.Deposited)
err = depositStore.UpdateDeposit(ctxb, d1)
require.NoError(t, err)
err = depositStore.UpdateDeposit(ctxb, d2)
require.NoError(t, err)
err = depositStore.UpdateDeposit(ctxb, d3)
require.NoError(t, err)
_, clientPubKey := test.CreateKey(1)
_, serverPubKey := test.CreateKey(2)
@ -124,6 +144,23 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
err = swapStore.CreateLoopIn(ctxb, &swapSucceeded)
require.NoError(t, err)
// Create failed swap. Failed is the last final state, so this
// exercises the state-list query boundary.
swapHashFailed := lntypes.Hash{0x3, 0x2, 0x3, 0x5}
swapFailed := StaticAddressLoopIn{
SwapHash: swapHashFailed,
SwapPreimage: lntypes.Preimage{0x3, 0x2, 0x3, 0x5},
DepositOutpoints: []string{d3.OutPoint.String()},
Deposits: []*deposit.Deposit{d3},
ClientPubkey: clientPubKey,
ServerPubkey: serverPubKey,
HtlcTimeoutSweepAddress: addr,
}
swapFailed.SetState(Failed)
err = swapStore.CreateLoopIn(ctxb, &swapFailed)
require.NoError(t, err)
pendingSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, PendingStates)
require.NoError(t, err)
@ -142,10 +179,12 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
finalizedSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, FinalStates)
require.NoError(t, err)
require.Len(t, finalizedSwaps, 1)
require.Len(t, finalizedSwaps, 2)
require.Equal(t, swapHashSucceeded, finalizedSwaps[0].SwapHash)
require.Equal(t, []string{d2.OutPoint.String()}, finalizedSwaps[0].DepositOutpoints)
require.Equal(t, Succeeded, finalizedSwaps[0].GetState())
require.Equal(t, swapHashFailed, finalizedSwaps[1].SwapHash)
require.Equal(t, Failed, finalizedSwaps[1].GetState())
finalizedDeposits := finalizedSwaps[0].Deposits
require.Len(t, finalizedDeposits, 1)
@ -258,6 +297,31 @@ 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))
err = swapStore.RecordStaticAddressRiskDecision(
ctx, lntypes.Hash{0x9, 0x9, 0x9},
ConfirmationRiskDecisionRejected,
)
require.ErrorIs(t, err, ErrLoopInNotFound)
require.Len(t, swap.Deposits, 2)
@ -286,3 +350,76 @@ func TestCreateLoopIn(t *testing.T) {
time.Microsecond,
)
}
// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins
// keep the original outpoint snapshot stored when the swap was created.
func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) {
ctxb := context.Background()
testDb := loopdb.NewTestDB(t)
testClock := clock.NewTestClock(time.Now())
defer testDb.Close()
depositStore := deposit.NewSqlStore(testDb.BaseDB)
swapStore := NewSqlStore(
loopdb.NewTypedStore[Querier](testDb), testClock,
&chaincfg.RegressionNetParams,
)
depositID, err := deposit.GetRandomDepositID()
require.NoError(t, err)
oldOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
Index: 0,
}
currentOutpoint := wire.OutPoint{
Hash: chainhash.Hash{0x5a, 0x6b, 0x7c, 0x8d},
Index: 1,
}
d := &deposit.Deposit{
ID: depositID,
OutPoint: oldOutpoint,
Value: btcutil.Amount(100_000),
TimeOutSweepPkScript: []byte{
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41,
},
}
require.NoError(t, depositStore.CreateDeposit(ctxb, d))
d.SetState(deposit.LoopingIn)
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
_, clientPubKey := test.CreateKey(1)
_, serverPubKey := test.CreateKey(2)
addr, err := btcutil.DecodeAddress(P2wkhAddr, nil)
require.NoError(t, err)
swapHash := lntypes.Hash{0x1, 0x2, 0x3, 0x4}
swap := StaticAddressLoopIn{
SwapHash: swapHash,
SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4},
DepositOutpoints: []string{oldOutpoint.String()},
Deposits: []*deposit.Deposit{d},
ClientPubkey: clientPubKey,
ServerPubkey: serverPubKey,
HtlcTimeoutSweepAddress: addr,
}
swap.SetState(SignHtlcTx)
require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap))
d.OutPoint = currentOutpoint
d.ConfirmationHeight = 42
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash)
require.NoError(t, err)
require.Equal(
t, []string{oldOutpoint.String()},
storedSwap.DepositOutpoints,
)
require.Len(t, storedSwap.Deposits, 1)
require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint)
require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight)
}

View file

@ -0,0 +1,72 @@
package loopin
import (
"context"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/lndclient"
)
// lndTxOutChecker checks outpoint availability using lnd's wallet transaction
// view. It returns nil for outputs already spent by a wallet-known transaction.
type lndTxOutChecker struct {
client lndclient.LightningClient
}
// NewLndTxOutChecker creates a TxOutChecker backed by lnd.
func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker {
return &lndTxOutChecker{
client: client,
}
}
// GetTxOut returns the tx output if lnd's transaction view still reports the
// outpoint as unspent.
func (c *lndTxOutChecker) GetTxOut(ctx context.Context,
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
endHeight := int32(0)
if includeMempool {
endHeight = -1
}
// We need lnd's wallet transaction view rather than only the funding
// transaction: a matching previous outpoint tells us the deposit has
// already been spent by a wallet-known transaction. When mempool spends
// matter, lnd exposes them through ListTransactions with endHeight=-1.
txs, err := c.client.ListTransactions(ctx, 0, endHeight)
if err != nil {
return nil, err
}
outpointStr := outpoint.String()
for _, tx := range txs {
for _, prevOutpoint := range tx.PreviousOutpoints {
if prevOutpoint.GetOutpoint() == outpointStr {
return nil, nil
}
}
}
for _, tx := range txs {
if tx.Tx == nil {
continue
}
txHash := tx.TxHash
if txHash == "" {
txHash = tx.Tx.TxHash().String()
}
if txHash != outpoint.Hash.String() {
continue
}
if int(outpoint.Index) >= len(tx.Tx.TxOut) {
return nil, nil
}
return tx.Tx.TxOut[outpoint.Index], nil
}
return nil, nil
}

View file

@ -310,6 +310,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 req.LocalFundingAmount != 0 {
deposits, err = staticutil.SelectDeposits(
deposits, req.LocalFundingAmount,
@ -325,6 +329,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.ConfirmationHeight <= 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.
@ -399,6 +411,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.ConfirmationHeight <= 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,6 +29,7 @@ type transitionCall struct {
}
type mockDepositManager struct {
activeDeposits []*deposit.Deposit
openingDeposits []*deposit.Deposit
getErr error
transitionErrs map[fsm.EventType]error
@ -44,15 +45,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 +469,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

@ -381,6 +381,15 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
}
}
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.ConfirmationHeight <= 0 {
return "", "", fmt.Errorf("can't withdraw, " +
"unconfirmed deposits can't be withdrawn")
}
}
var (
withdrawalAddress btcutil.Address
err error

View file

@ -170,7 +170,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

@ -218,6 +218,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 {