mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Merge pull request #1141 from lightninglabs/dyn-conf-tracker-staging
Some checks failed
CI / RPC compilation check (push) Has been cancelled
CI / SQL compilation check (push) Has been cancelled
CI / go mod check (push) Has been cancelled
CI / build and lint code (push) Has been cancelled
CI / verify that auto-generated documentation is up-to-date (push) Has been cancelled
CI / run unit-test sqlite3 race (push) Has been cancelled
CI / run unit-test postgres race (push) Has been cancelled
CI / run LiT itests (push) Has been cancelled
CI / run LiT unit tests (push) Has been cancelled
Some checks failed
CI / RPC compilation check (push) Has been cancelled
CI / SQL compilation check (push) Has been cancelled
CI / go mod check (push) Has been cancelled
CI / build and lint code (push) Has been cancelled
CI / verify that auto-generated documentation is up-to-date (push) Has been cancelled
CI / run unit-test sqlite3 race (push) Has been cancelled
CI / run unit-test postgres race (push) Has been cancelled
CI / run LiT itests (push) Has been cancelled
CI / run LiT unit tests (push) Has been cancelled
staticaddr: dyn-conf-tracker
This commit is contained in:
commit
39c97e60d2
50 changed files with 7563 additions and 384 deletions
|
|
@ -4,14 +4,17 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/lightninglabs/loop/labels"
|
||||
"github.com/lightninglabs/loop/looprpc"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
"github.com/lightninglabs/loop/staticaddr/loopin"
|
||||
"github.com/lightninglabs/loop/swapserverrpc"
|
||||
"github.com/lightningnetwork/lnd"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
|
@ -553,11 +556,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
allDeposits := depositList.FilteredDeposits
|
||||
|
||||
if len(allDeposits) == 0 {
|
||||
errString := fmt.Sprintf("no confirmed deposits available, "+
|
||||
"deposits need at least %v confirmations",
|
||||
deposit.MinConfs)
|
||||
|
||||
return errors.New(errString)
|
||||
return errors.New("no deposited outputs available")
|
||||
}
|
||||
|
||||
var depositOutpoints []string
|
||||
|
|
@ -614,6 +613,28 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Warn the user if any selected deposits have fewer than 6
|
||||
// confirmations, as the swap payment won't be received immediately
|
||||
// for those.
|
||||
summary, err := client.GetStaticAddressSummary(
|
||||
ctx, &looprpc.StaticAddressSummaryRequest{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
depositsToCheck := warningDepositOutpoints(
|
||||
allDeposits, depositOutpoints, autoSelectDepositsForQuote,
|
||||
quoteReq.Amt,
|
||||
)
|
||||
warning := lowConfDepositWarning(
|
||||
allDeposits, depositsToCheck,
|
||||
int64(summary.RelativeExpiryBlocks),
|
||||
)
|
||||
if warning != "" {
|
||||
fmt.Println(warning)
|
||||
}
|
||||
|
||||
if !(cmd.Bool("force") || cmd.Bool("f")) {
|
||||
err = displayInDetails(quoteReq, quote, cmd.Bool("verbose"))
|
||||
if err != nil {
|
||||
|
|
@ -669,6 +690,162 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
|
|||
return outpoints
|
||||
}
|
||||
|
||||
var warningSelectionDustLimit = int64(lnwallet.DustLimitForSize(input.P2TRSize))
|
||||
|
||||
// warningDepositOutpoints returns the deposit outpoints to check for
|
||||
// low-confirmation warnings.
|
||||
func warningDepositOutpoints(allDeposits []*looprpc.Deposit,
|
||||
selectedOutpoints []string, autoSelect bool, targetAmount int64) []string {
|
||||
|
||||
if !autoSelect {
|
||||
return selectedOutpoints
|
||||
}
|
||||
|
||||
return autoSelectedWarningOutpoints(allDeposits, targetAmount)
|
||||
}
|
||||
|
||||
// autoSelectedWarningOutpoints returns the outpoints selected by the same
|
||||
// ordering used for automatic static loop-in deposit selection.
|
||||
func autoSelectedWarningOutpoints(allDeposits []*looprpc.Deposit,
|
||||
targetAmount int64) []string {
|
||||
|
||||
if targetAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with staticaddr/loopin.SelectDeposits.
|
||||
deposits := filterSwappableWarningDeposits(allDeposits)
|
||||
sort.Slice(deposits, func(i, j int) bool {
|
||||
iConfirmed := deposits[i].ConfirmationHeight > 0
|
||||
jConfirmed := deposits[j].ConfirmationHeight > 0
|
||||
if iConfirmed != jConfirmed {
|
||||
return iConfirmed
|
||||
}
|
||||
|
||||
if deposits[i].Value == deposits[j].Value {
|
||||
return deposits[i].BlocksUntilExpiry <
|
||||
deposits[j].BlocksUntilExpiry
|
||||
}
|
||||
|
||||
return deposits[i].Value > deposits[j].Value
|
||||
})
|
||||
|
||||
selectedOutpoints := make([]string, 0, len(deposits))
|
||||
var selectedAmount int64
|
||||
for _, deposit := range deposits {
|
||||
selectedOutpoints = append(selectedOutpoints, deposit.Outpoint)
|
||||
selectedAmount += deposit.Value
|
||||
if selectedAmount == targetAmount {
|
||||
return selectedOutpoints
|
||||
}
|
||||
|
||||
if selectedAmount > targetAmount &&
|
||||
selectedAmount-targetAmount >= warningSelectionDustLimit {
|
||||
|
||||
return selectedOutpoints
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSwappableWarningDeposits filters deposits for CLI warning selection.
|
||||
func filterSwappableWarningDeposits(
|
||||
allDeposits []*looprpc.Deposit) []*looprpc.Deposit {
|
||||
|
||||
swappable := make([]*looprpc.Deposit, 0, len(allDeposits))
|
||||
minBlocksUntilExpiry := int64(
|
||||
loopin.DefaultLoopInOnChainCltvDelta + loopin.DepositHtlcDelta,
|
||||
)
|
||||
for _, deposit := range allDeposits {
|
||||
// Unconfirmed deposits remain swappable because their CSV timeout has
|
||||
// not started yet. This mirrors loopin.IsSwappable.
|
||||
if deposit.ConfirmationHeight > 0 &&
|
||||
deposit.BlocksUntilExpiry < minBlocksUntilExpiry {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
swappable = append(swappable, deposit)
|
||||
}
|
||||
|
||||
return swappable
|
||||
}
|
||||
|
||||
// conservativeWarningConfs is the highest default confirmation tier used by
|
||||
// the server's dynamic confirmation-risk policy.
|
||||
//
|
||||
// The CLI does not currently know the server's exact policy, so we use this
|
||||
// conservative threshold for warnings without promising immediate execution.
|
||||
const conservativeWarningConfs = 6
|
||||
|
||||
// lowConfDepositWarning checks the selected deposits against a conservative
|
||||
// confirmation threshold and returns a warning string if any are found.
|
||||
func lowConfDepositWarning(allDeposits []*looprpc.Deposit,
|
||||
selectedOutpoints []string, csvExpiry int64) string {
|
||||
|
||||
depositMap := make(map[string]*looprpc.Deposit, len(allDeposits))
|
||||
for _, d := range allDeposits {
|
||||
depositMap[d.Outpoint] = d
|
||||
}
|
||||
|
||||
var lowConfEntries []string
|
||||
for _, op := range selectedOutpoints {
|
||||
d, ok := depositMap[op]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var confs int64
|
||||
switch {
|
||||
case d.ConfirmationHeight <= 0:
|
||||
confs = 0
|
||||
|
||||
case csvExpiry > 0:
|
||||
// For confirmed deposits we can compute
|
||||
// confirmations as CSVExpiry - BlocksUntilExpiry + 1.
|
||||
confs = csvExpiry - d.BlocksUntilExpiry + 1
|
||||
|
||||
default:
|
||||
// Can't determine confirmations without the CSV expiry.
|
||||
continue
|
||||
}
|
||||
|
||||
if confs >= conservativeWarningConfs {
|
||||
continue
|
||||
}
|
||||
|
||||
if confs == 0 {
|
||||
lowConfEntries = append(
|
||||
lowConfEntries,
|
||||
fmt.Sprintf(" - %s (unconfirmed)", op),
|
||||
)
|
||||
} else {
|
||||
lowConfEntries = append(
|
||||
lowConfEntries,
|
||||
fmt.Sprintf(
|
||||
" - %s (%d confirmations)", op,
|
||||
confs,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(lowConfEntries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"\nWARNING: The following deposits are below the "+
|
||||
"conservative %d-confirmation threshold:\n%s\n"+
|
||||
"The swap payment for these deposits may wait for "+
|
||||
"more confirmations depending on the server's "+
|
||||
"confirmation-risk policy.\n",
|
||||
conservativeWarningConfs,
|
||||
strings.Join(lowConfEntries, "\n"),
|
||||
)
|
||||
}
|
||||
|
||||
func displayNewAddressWarning() error {
|
||||
fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " +
|
||||
".loop under your home directory will take your ability to " +
|
||||
|
|
|
|||
220
cmd/loop/staticaddr_test.go
Normal file
220
cmd/loop/staticaddr_test.go
Normal 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)
|
||||
}
|
||||
2
cmd/loop/testdata/sessions/AGENTS.md
vendored
2
cmd/loop/testdata/sessions/AGENTS.md
vendored
|
|
@ -71,7 +71,7 @@ Base URL: `http://127.0.0.1:12345`
|
|||
| `quote/` | `loop quote out` (success + verbose), `loop quote in` (help + verbose), `loop quote out` (help), `loop quote in` (deposit_outpoint success), `loop quote in` (positional + last_hop) |
|
||||
| `static/` | `loop static withdraw` (no selection error), `loop static withdraw` (invalid utxo), `loop static withdraw` (all success), `loop static withdraw` (utxo + dest_addr success), `loop static listwithdrawals`, `loop static listswaps` |
|
||||
| `static-autoloop/` | `loop setparams --loopinsource static-address` (success + no-experimental error), `loop getparams` (static-address loop-in source), `loop suggestswaps` (static loop-in suggestion) |
|
||||
| `static-loop-in/` | `loop static new`, `loop static` (help), `loop static listunspent` (incl alias), `loop static listdeposits`, `loop static summary`, `loop static in` (multiple args/flags cases), `loop static in` (duplicate outpoints), `loop static in` (positional low amount error), `loop static in` (positional + last_hop + payment_timeout), `loop static in` (all cancel) |
|
||||
| `static-loop-in/` | `loop static new`, `loop static` (help), `loop static listunspent` (incl alias), `loop static listdeposits`, `loop static summary`, `loop static in` (multiple args/flags cases), `loop static in` (duplicate outpoints), `loop static in` (positional low amount error), `loop static in` (positional + last_hop + payment_timeout), `loop static in` (all cancel), `loop static in` (explicit and automatically selected low-confirmation warnings) |
|
||||
| `static-filters/` | `loop static listdeposits --filter ...` for each state (deposited/withdrawing/withdrawn/looping_in/looped_in/publish_expired_deposit/sweep_htlc_timeout/htlc_timeout_swept/wait_for_expiry_sweep/expired/failed) |
|
||||
| `swaps/` | `loop listswaps` (success + conflicting filters + loop_out_only filters + loop_in_only), `loop swapinfo` (success + invalid id + id flag errors), `loop abandonswap` (help + invalid id + success) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
242
cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json
vendored
Normal file
242
cmd/loop/testdata/sessions/static-loop-in/25_loop-static-in-low-conf-utxo.json
vendored
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
{
|
||||
"metadata": {
|
||||
"args": [
|
||||
"loop",
|
||||
"static",
|
||||
"in",
|
||||
"--amt",
|
||||
"500000",
|
||||
"--utxo",
|
||||
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
|
||||
"--network",
|
||||
"regtest"
|
||||
],
|
||||
"env": {},
|
||||
"version": "0.31.7-beta commit=vbump-lndclient-70-g352a68cd43f1976a937faaf76041bc078fdd16f6 commit_hash=352a68cd43f1976a937faaf76041bc078fdd16f6",
|
||||
"duration": 2826015164,
|
||||
"clock_start_unix": 1769407086
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"time_ms": 3,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.ListStaticAddressDepositsRequest",
|
||||
"payload": {
|
||||
"state_filter": "DEPOSITED",
|
||||
"outpoints": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 22,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.ListStaticAddressDepositsResponse",
|
||||
"payload": {
|
||||
"filtered_deposits": [
|
||||
{
|
||||
"id": "6mq78FccC6ghF66fIIZhTqzqiykT3AVEtwwA3ng1PnE=",
|
||||
"state": "DEPOSITED",
|
||||
"outpoint": "188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
|
||||
"value": "2500000",
|
||||
"confirmation_height": "131",
|
||||
"blocks_until_expiry": "14396",
|
||||
"swap_hash": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 22,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetLoopInQuote",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.QuoteRequest",
|
||||
"payload": {
|
||||
"amt": "500000",
|
||||
"conf_target": 0,
|
||||
"external_htlc": false,
|
||||
"swap_publication_deadline": "0",
|
||||
"loop_in_last_hop": "",
|
||||
"loop_in_route_hints": [],
|
||||
"private": false,
|
||||
"deposit_outpoints": [
|
||||
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0"
|
||||
],
|
||||
"asset_info": null,
|
||||
"auto_select_deposits": false,
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 65,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetLoopInQuote",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.InQuoteResponse",
|
||||
"payload": {
|
||||
"swap_fee_sat": "1824",
|
||||
"htlc_publish_fee_sat": "0",
|
||||
"cltv_delta": 0,
|
||||
"conf_target": 0,
|
||||
"quoted_amt": "500000"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 65,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.StaticAddressSummaryRequest",
|
||||
"payload": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 65,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.StaticAddressSummaryResponse",
|
||||
"payload": {
|
||||
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
|
||||
"relative_expiry_blocks": "14400",
|
||||
"total_num_deposits": 1,
|
||||
"value_unconfirmed_satoshis": "0",
|
||||
"value_deposited_satoshis": "2500000",
|
||||
"value_expired_satoshis": "0",
|
||||
"value_withdrawn_satoshis": "0",
|
||||
"value_looped_in_satoshis": "0",
|
||||
"value_htlc_timeout_sweeps_satoshis": "0",
|
||||
"value_channels_opened": "0"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 65,
|
||||
"kind": "stdout",
|
||||
"data": {
|
||||
"lines": [
|
||||
"\n",
|
||||
"WARNING: The following deposits are below the conservative 6-confirmation threshold:\n",
|
||||
" - 188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0 (5 confirmations)\n",
|
||||
"The swap payment for these deposits may wait for more confirmations depending on the server's confirmation-risk policy.\n",
|
||||
"\n",
|
||||
"Previously deposited on-chain: 500000 sat\n",
|
||||
"Receive off-chain: 498176 sat\n",
|
||||
"Estimated total fee: 1824 sat\n",
|
||||
"\n",
|
||||
"CONTINUE SWAP? (y/n): {\n",
|
||||
" \"amount\": \"2500000\",\n",
|
||||
" \"change\": \"2000000\",\n",
|
||||
" \"fast\": false,\n",
|
||||
" \"htlc_cltv\": 1136,\n",
|
||||
" \"initiation_height\": 136,\n",
|
||||
" \"initiator\": \"loop-cli\",\n",
|
||||
" \"label\": \"\",\n",
|
||||
" \"max_swap_fee_satoshis\": \"1824\",\n",
|
||||
" \"payment_timeout_seconds\": 60,\n",
|
||||
" \"protocol_version\": \"V0\",\n",
|
||||
" \"quoted_swap_fee_satoshis\": \"1824\",\n",
|
||||
" \"state\": \"SignHtlcTx\",\n",
|
||||
" \"swap_amount\": \"500000\",\n",
|
||||
" \"swap_hash\": \"9f19fb5042a5de6da2f1ce183c5e224fd7802db408e9afdd598ee8174b2bce3f\",\n",
|
||||
" \"used_deposits\": [\n",
|
||||
" {\n",
|
||||
" \"blocks_until_expiry\": \"14396\",\n",
|
||||
" \"confirmation_height\": \"131\",\n",
|
||||
" \"id\": \"ea6abbf0571c0ba82117ae9f2086614eacea8b2913dc0544b70c00de78353e71\",\n",
|
||||
" \"outpoint\": \"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0\",\n",
|
||||
" \"state\": \"LOOPING_IN\",\n",
|
||||
" \"swap_hash\": \"\",\n",
|
||||
" \"value\": \"2500000\"\n",
|
||||
" }\n",
|
||||
" ]\n",
|
||||
"}\n"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 2358,
|
||||
"kind": "stdin",
|
||||
"data": {
|
||||
"text": "y\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 2358,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.StaticAddressLoopInRequest",
|
||||
"payload": {
|
||||
"outpoints": [
|
||||
"188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0"
|
||||
],
|
||||
"max_swap_fee_satoshis": "1824",
|
||||
"last_hop": "",
|
||||
"label": "",
|
||||
"initiator": "loop-cli",
|
||||
"route_hints": [],
|
||||
"private": false,
|
||||
"payment_timeout_seconds": 60,
|
||||
"amount": "500000",
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 2824,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.StaticAddressLoopInResponse",
|
||||
"payload": {
|
||||
"swap_hash": "nxn7UEKl3m2i8c4YPF4iT9eALbQI6a/dWY7oF0srzj8=",
|
||||
"state": "SignHtlcTx",
|
||||
"amount": "2500000",
|
||||
"htlc_cltv": 1136,
|
||||
"quoted_swap_fee_satoshis": "1824",
|
||||
"max_swap_fee_satoshis": "1824",
|
||||
"initiation_height": 136,
|
||||
"protocol_version": "V0",
|
||||
"label": "",
|
||||
"initiator": "loop-cli",
|
||||
"payment_timeout_seconds": 60,
|
||||
"used_deposits": [
|
||||
{
|
||||
"id": "6mq78FccC6ghF66fIIZhTqzqiykT3AVEtwwA3ng1PnE=",
|
||||
"state": "LOOPING_IN",
|
||||
"outpoint": "188f55042e49cfa9942cc1f8e216c5e8679a7036e9ee6449d0fcc6c6b81561be:0",
|
||||
"value": "2500000",
|
||||
"confirmation_height": "131",
|
||||
"blocks_until_expiry": "14396",
|
||||
"swap_hash": ""
|
||||
}
|
||||
],
|
||||
"swap_amount": "500000",
|
||||
"change": "2000000",
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 2826,
|
||||
"kind": "exit",
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
231
cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json
vendored
Normal file
231
cmd/loop/testdata/sessions/static-loop-in/26_loop-static-in-auto-unconfirmed.json
vendored
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
{
|
||||
"metadata": {
|
||||
"args": [
|
||||
"/home/user/bin/loop",
|
||||
"static",
|
||||
"in",
|
||||
"--network",
|
||||
"regtest",
|
||||
"500000",
|
||||
"--payment_timeout",
|
||||
"30s",
|
||||
"--last_hop",
|
||||
"0271d6e29301159d9e1cc5d3983479a51f3b3c0c682eda7f16aa1f47dfe09b22f7",
|
||||
"--force"
|
||||
],
|
||||
"env": {
|
||||
"HOME": "/home/user"
|
||||
},
|
||||
"version": "0.31.7-beta commit=v0.31.7-beta-28-g6d8ddfc59ddc2dcfd1a9b4e4b3c53a9cf15dd845 commit_hash=6d8ddfc59ddc2dcfd1a9b4e4b3c53a9cf15dd845",
|
||||
"duration": 1078774451,
|
||||
"clock_start_unix": 1769407086
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"time_ms": 4,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.ListStaticAddressDepositsRequest",
|
||||
"payload": {
|
||||
"state_filter": "DEPOSITED",
|
||||
"outpoints": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 179,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/ListStaticAddressDeposits",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.ListStaticAddressDepositsResponse",
|
||||
"payload": {
|
||||
"filtered_deposits": [
|
||||
{
|
||||
"id": "j71tovlF3ikFqn+pOGB0TZOH00ZEhDYOluRnpR3jvJ0=",
|
||||
"state": "DEPOSITED",
|
||||
"outpoint": "9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0",
|
||||
"value": "500000",
|
||||
"confirmation_height": "0",
|
||||
"blocks_until_expiry": "14400",
|
||||
"swap_hash": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 180,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetLoopInQuote",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.QuoteRequest",
|
||||
"payload": {
|
||||
"amt": "500000",
|
||||
"conf_target": 0,
|
||||
"external_htlc": false,
|
||||
"swap_publication_deadline": "0",
|
||||
"loop_in_last_hop": "AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3",
|
||||
"loop_in_route_hints": [],
|
||||
"private": false,
|
||||
"deposit_outpoints": [],
|
||||
"asset_info": null,
|
||||
"auto_select_deposits": true,
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 500,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetLoopInQuote",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.InQuoteResponse",
|
||||
"payload": {
|
||||
"swap_fee_sat": "1824",
|
||||
"htlc_publish_fee_sat": "0",
|
||||
"cltv_delta": 0,
|
||||
"conf_target": 0,
|
||||
"quoted_amt": "500000"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 500,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.StaticAddressSummaryRequest",
|
||||
"payload": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 500,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/GetStaticAddressSummary",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.StaticAddressSummaryResponse",
|
||||
"payload": {
|
||||
"static_address": "bcrt1pfu9g59aqtxd39653f76y4c8z7r3t9tmcvrvhl57a3dgj3epdwxdqcd9fpw",
|
||||
"relative_expiry_blocks": "14400",
|
||||
"total_num_deposits": 1,
|
||||
"value_unconfirmed_satoshis": "500000",
|
||||
"value_deposited_satoshis": "0",
|
||||
"value_expired_satoshis": "0",
|
||||
"value_withdrawn_satoshis": "0",
|
||||
"value_looped_in_satoshis": "0",
|
||||
"value_htlc_timeout_sweeps_satoshis": "0",
|
||||
"value_channels_opened": "0"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 500,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
|
||||
"event": "request",
|
||||
"message_type": "looprpc.StaticAddressLoopInRequest",
|
||||
"payload": {
|
||||
"outpoints": [],
|
||||
"max_swap_fee_satoshis": "1824",
|
||||
"last_hop": "AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3",
|
||||
"label": "",
|
||||
"initiator": "loop-cli",
|
||||
"route_hints": [],
|
||||
"private": false,
|
||||
"payment_timeout_seconds": 30,
|
||||
"amount": "500000",
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 1078,
|
||||
"kind": "grpc",
|
||||
"data": {
|
||||
"method": "/looprpc.SwapClient/StaticAddressLoopIn",
|
||||
"event": "response",
|
||||
"message_type": "looprpc.StaticAddressLoopInResponse",
|
||||
"payload": {
|
||||
"swap_hash": "hDAjN0JANkGTlqt5ZN14uFsaSBqfHbc9tc3e5XwkQ+c=",
|
||||
"state": "SignHtlcTx",
|
||||
"amount": "500000",
|
||||
"htlc_cltv": 1165,
|
||||
"quoted_swap_fee_satoshis": "1824",
|
||||
"max_swap_fee_satoshis": "1824",
|
||||
"initiation_height": 165,
|
||||
"protocol_version": "V0",
|
||||
"label": "",
|
||||
"initiator": "loop-cli",
|
||||
"payment_timeout_seconds": 30,
|
||||
"used_deposits": [
|
||||
{
|
||||
"id": "j71tovlF3ikFqn+pOGB0TZOH00ZEhDYOluRnpR3jvJ0=",
|
||||
"state": "LOOPING_IN",
|
||||
"outpoint": "9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0",
|
||||
"value": "500000",
|
||||
"confirmation_height": "0",
|
||||
"blocks_until_expiry": "14400",
|
||||
"swap_hash": ""
|
||||
}
|
||||
],
|
||||
"swap_amount": "500000",
|
||||
"change": "0",
|
||||
"fast": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 1078,
|
||||
"kind": "stdout",
|
||||
"data": {
|
||||
"lines": [
|
||||
"\n",
|
||||
"WARNING: The following deposits are below the conservative 6-confirmation threshold:\n",
|
||||
" - 9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0 (unconfirmed)\n",
|
||||
"The swap payment for these deposits may wait for more confirmations depending on the server's confirmation-risk policy.\n",
|
||||
"\n",
|
||||
"{\n",
|
||||
" \"amount\": \"500000\",\n",
|
||||
" \"change\": \"0\",\n",
|
||||
" \"fast\": false,\n",
|
||||
" \"htlc_cltv\": 1165,\n",
|
||||
" \"initiation_height\": 165,\n",
|
||||
" \"initiator\": \"loop-cli\",\n",
|
||||
" \"label\": \"\",\n",
|
||||
" \"max_swap_fee_satoshis\": \"1824\",\n",
|
||||
" \"payment_timeout_seconds\": 30,\n",
|
||||
" \"protocol_version\": \"V0\",\n",
|
||||
" \"quoted_swap_fee_satoshis\": \"1824\",\n",
|
||||
" \"state\": \"SignHtlcTx\",\n",
|
||||
" \"swap_amount\": \"500000\",\n",
|
||||
" \"swap_hash\": \"84302337424036419396ab7964dd78b85b1a481a9f1db73db5cddee57c2443e7\",\n",
|
||||
" \"used_deposits\": [\n",
|
||||
" {\n",
|
||||
" \"blocks_until_expiry\": \"14400\",\n",
|
||||
" \"confirmation_height\": \"0\",\n",
|
||||
" \"id\": \"8fbd6da2f945de2905aa7fa93860744d9387d3464484360e96e467a51de3bc9d\",\n",
|
||||
" \"outpoint\": \"9fa0d5dd5348794aa0541dd2729497f0907890606d044e1c4757bdc848f38df8:0\",\n",
|
||||
" \"state\": \"LOOPING_IN\",\n",
|
||||
" \"swap_hash\": \"\",\n",
|
||||
" \"value\": \"500000\"\n",
|
||||
" }\n",
|
||||
" ]\n",
|
||||
"}\n"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"time_ms": 1078,
|
||||
"kind": "exit",
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 := ¬ifications.Config{
|
||||
Client: loop_swaprpc.NewSwapServerClient(swapClient.Conn),
|
||||
CurrentToken: swapClient.L402Store.CurrentToken,
|
||||
PersistStaticLoopInRiskDecision: func(ctx context.Context,
|
||||
swapHash lntypes.Hash, accepted bool) error {
|
||||
|
||||
decision := loopin.ConfirmationRiskDecisionRejected
|
||||
if accepted {
|
||||
decision = loopin.ConfirmationRiskDecisionAccepted
|
||||
}
|
||||
|
||||
return staticAddressLoopInStore.
|
||||
RecordStaticAddressRiskDecision(
|
||||
ctx, swapHash, decision,
|
||||
)
|
||||
},
|
||||
}
|
||||
notificationManager := notifications.NewManager(notificationCfg)
|
||||
|
||||
|
|
@ -662,12 +682,6 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
|
|||
}
|
||||
openChannelManager = openchannel.NewManager(openChannelCfg)
|
||||
|
||||
// Static address loop-in manager setup.
|
||||
staticAddressLoopInStore := loopin.NewSqlStore(
|
||||
loopdb.NewTypedStore[loopin.Querier](baseDb),
|
||||
clock.NewDefaultClock(), d.lnd.ChainParams,
|
||||
)
|
||||
|
||||
// Run the deposit swap hash migration.
|
||||
err = loopin.MigrateDepositSwapHash(
|
||||
d.mainCtx, swapDb, depositStore, staticAddressLoopInStore,
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ type swapClientServer struct {
|
|||
reservationManager *reservation.Manager
|
||||
instantOutManager *instantout.Manager
|
||||
staticAddressManager *address.Manager
|
||||
depositManager *deposit.Manager
|
||||
depositManager staticAddressDepositManager
|
||||
withdrawalManager *withdraw.Manager
|
||||
staticLoopInManager *loopin.Manager
|
||||
openChannelManager *openchannel.Manager
|
||||
|
|
@ -112,6 +112,31 @@ type swapClientServer struct {
|
|||
stopDaemon func()
|
||||
}
|
||||
|
||||
// staticAddressDepositManager is the deposit manager behavior required by the
|
||||
// RPC server.
|
||||
type staticAddressDepositManager interface {
|
||||
// EnsureDepositsFresh reconciles tracked deposits with lnd's current
|
||||
// wallet view before user-facing deposit selection.
|
||||
EnsureDepositsFresh(context.Context) error
|
||||
|
||||
// GetActiveDepositsInState returns active deposits that are currently in
|
||||
// the requested state.
|
||||
GetActiveDepositsInState(fsm.StateType) ([]*deposit.Deposit, error)
|
||||
|
||||
// DepositsForOutpoints returns known deposit records for the requested
|
||||
// outpoints, optionally skipping unknown outpoints.
|
||||
DepositsForOutpoints(context.Context, []string, bool) (
|
||||
[]*deposit.Deposit, error)
|
||||
|
||||
// GetVisibleDeposits returns deposits that should be shown in normal
|
||||
// user-facing views.
|
||||
GetVisibleDeposits(context.Context) ([]*deposit.Deposit, error)
|
||||
|
||||
// GetAllDeposits returns all known deposit records, including historical
|
||||
// records that are no longer user-visible.
|
||||
GetAllDeposits(context.Context) ([]*deposit.Deposit, error)
|
||||
}
|
||||
|
||||
// LoopOut initiates a loop out swap with the given parameters. The call returns
|
||||
// after the swap has been set up with the swap server. From that point onwards,
|
||||
// progress can be tracked via the LoopOutStatus stream that is returned from
|
||||
|
|
@ -921,6 +946,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
|
|||
// number of deposits to quote for.
|
||||
numDeposits := 0
|
||||
if autoSelectDeposits {
|
||||
err = s.depositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
deposits, err := s.depositManager.GetActiveDepositsInState(
|
||||
deposit.Deposited,
|
||||
)
|
||||
|
|
@ -955,6 +986,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
|
|||
|
||||
numDeposits = len(selectedDeposits)
|
||||
} else if len(req.DepositOutpoints) > 0 {
|
||||
err = s.depositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
// If deposits are selected, we need to retrieve them to
|
||||
// calculate the total value which we request a quote for.
|
||||
depositList, err := s.ListStaticAddressDeposits(
|
||||
|
|
@ -977,15 +1014,24 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
|
|||
return nil, fmt.Errorf("expected %d deposits, got %d",
|
||||
len(req.DepositOutpoints),
|
||||
len(depositList.FilteredDeposits))
|
||||
} else {
|
||||
numDeposits = len(depositList.FilteredDeposits)
|
||||
}
|
||||
numDeposits = len(depositList.FilteredDeposits)
|
||||
|
||||
// In case we quote for deposits, we send the server both the
|
||||
// selected value and the number of deposits. This is so the
|
||||
// server can probe the selected value and calculate the per
|
||||
// input fee.
|
||||
for _, deposit := range depositList.FilteredDeposits {
|
||||
// ListStaticAddressDeposits only returns deposits that are visible
|
||||
// in the manager's live view. For a manual quote we additionally
|
||||
// require the current state to be Deposited so stale client-side
|
||||
// outpoint selection fails early instead of making it to swap
|
||||
// initiation.
|
||||
if deposit.State != looprpc.DepositState_DEPOSITED {
|
||||
return nil, fmt.Errorf("deposit %s is not "+
|
||||
"currently available", deposit.Outpoint)
|
||||
}
|
||||
|
||||
totalDepositAmount += btcutil.Amount(
|
||||
deposit.Value,
|
||||
)
|
||||
|
|
@ -1693,58 +1739,40 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
|
|||
}
|
||||
|
||||
// ListUnspentRaw returns the unspent wallet view of the backing lnd
|
||||
// wallet. It might be that deposits show up there that are actually
|
||||
// not spendable because they already have been used but not yet spent
|
||||
// by the server. We filter out such deposits here.
|
||||
// wallet. Static loop-in initiation requires an active deposit record,
|
||||
// so only deposits that are both wallet-visible and tracked as
|
||||
// Deposited are returned here.
|
||||
var (
|
||||
outpoints []string
|
||||
isUnspent = make(map[wire.OutPoint]struct{})
|
||||
)
|
||||
|
||||
// Keep track of confirmed outpoints that we need to check against our
|
||||
// database.
|
||||
confirmedToCheck := make(map[wire.OutPoint]struct{})
|
||||
|
||||
for _, utxo := range utxos {
|
||||
if utxo.Confirmations < deposit.MinConfs {
|
||||
// Unconfirmed deposits are always available.
|
||||
isUnspent[utxo.OutPoint] = struct{}{}
|
||||
} else {
|
||||
// Confirmed deposits need to be checked.
|
||||
outpoints = append(outpoints, utxo.OutPoint.String())
|
||||
confirmedToCheck[utxo.OutPoint] = struct{}{}
|
||||
}
|
||||
outpoints = append(outpoints, utxo.OutPoint.String())
|
||||
}
|
||||
|
||||
err = s.depositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check the spent status of the deposits by looking at their states.
|
||||
ignoreUnknownOutpoints := false
|
||||
ignoreUnknownOutpoints := true
|
||||
deposits, err := s.depositManager.DepositsForOutpoints(
|
||||
ctx, outpoints, ignoreUnknownOutpoints,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
// A nil deposit means we don't have a record for it. We'll
|
||||
// handle this case after the loop.
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the deposit is in the "Deposited" state, it's available.
|
||||
if d.IsInState(deposit.Deposited) {
|
||||
isUnspent[d.OutPoint] = struct{}{}
|
||||
}
|
||||
|
||||
// We have a record for this deposit, so we no longer need to
|
||||
// check it.
|
||||
delete(confirmedToCheck, d.OutPoint)
|
||||
}
|
||||
|
||||
// Any remaining outpoints in confirmedToCheck are ones that lnd knows
|
||||
// about but we don't. These are new, unspent deposits.
|
||||
for op := range confirmedToCheck {
|
||||
isUnspent[op] = struct{}{}
|
||||
}
|
||||
|
||||
// Prepare the list of unspent deposits for the rpc response.
|
||||
|
|
@ -1784,6 +1812,12 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|||
return nil, fmt.Errorf("must select either all or some utxos")
|
||||
|
||||
case isAllSelected:
|
||||
err = s.depositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
deposits, err := s.depositManager.GetActiveDepositsInState(
|
||||
deposit.Deposited,
|
||||
)
|
||||
|
|
@ -1791,8 +1825,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
outpoints = append(outpoints, d.OutPoint)
|
||||
outpoints, err = withdrawAllDepositOutpoints(deposits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case isUtxoSelected:
|
||||
|
|
@ -1815,6 +1850,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|||
}, err
|
||||
}
|
||||
|
||||
// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
|
||||
// withdrawal request. The request must fail if any deposited output is still
|
||||
// unconfirmed because `all` should not silently downgrade to a subset.
|
||||
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
|
||||
error) {
|
||||
|
||||
outpoints := make([]wire.OutPoint, 0, len(deposits))
|
||||
for _, d := range deposits {
|
||||
if d.GetConfirmationHeight() <= 0 {
|
||||
return nil, fmt.Errorf("can't withdraw all deposits while " +
|
||||
"some deposits are unconfirmed")
|
||||
}
|
||||
|
||||
outpoints = append(outpoints, d.OutPoint)
|
||||
}
|
||||
|
||||
return outpoints, nil
|
||||
}
|
||||
|
||||
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
|
||||
// deposits behind the static address and displays properties like value,
|
||||
// state or blocks til expiry.
|
||||
|
|
@ -1830,7 +1884,7 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
|
|||
"outpoints")
|
||||
}
|
||||
|
||||
allDeposits, err := s.depositManager.GetAllDeposits(ctx)
|
||||
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1988,9 +2042,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
|
|||
for _, d := range ds {
|
||||
state := toClientDepositState(d.GetState())
|
||||
confirmationHeight := d.GetConfirmationHeight()
|
||||
blocksUntilExpiry := confirmationHeight +
|
||||
int64(addrParams.Expiry) -
|
||||
int64(lndInfo.BlockHeight)
|
||||
blocksUntilExpiry := depositBlocksUntilExpiry(
|
||||
confirmationHeight, addrParams.Expiry,
|
||||
int64(lndInfo.BlockHeight),
|
||||
)
|
||||
|
||||
pd := &looprpc.Deposit{
|
||||
Id: d.ID[:],
|
||||
|
|
@ -2066,7 +2121,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
|
|||
_ *looprpc.StaticAddressSummaryRequest) (
|
||||
*looprpc.StaticAddressSummaryResponse, error) {
|
||||
|
||||
allDeposits, err := s.depositManager.GetAllDeposits(ctx)
|
||||
allDeposits, err := s.depositManager.GetVisibleDeposits(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -2082,23 +2137,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
|
|||
htlcTimeoutSwept int64
|
||||
)
|
||||
|
||||
// Value unconfirmed.
|
||||
utxos, err := s.staticAddressManager.ListUnspent(
|
||||
ctx, 0, deposit.MinConfs-1,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range utxos {
|
||||
valueUnconfirmed += int64(u.Value)
|
||||
}
|
||||
|
||||
// Confirmed total values by category.
|
||||
// Total values by category.
|
||||
for _, d := range allDeposits {
|
||||
value := int64(d.Value)
|
||||
switch d.GetState() {
|
||||
case deposit.Deposited:
|
||||
valueDeposited += value
|
||||
if d.GetConfirmationHeight() <= 0 {
|
||||
valueUnconfirmed += value
|
||||
} else {
|
||||
valueDeposited += value
|
||||
}
|
||||
|
||||
case deposit.Expired:
|
||||
valueExpired += value
|
||||
|
|
@ -2248,13 +2296,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
|
|||
return err
|
||||
}
|
||||
for i := range len(deposits) {
|
||||
deposits[i].BlocksUntilExpiry =
|
||||
deposits[i].ConfirmationHeight +
|
||||
int64(params.Expiry) - bestBlockHeight
|
||||
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
|
||||
deposits[i].ConfirmationHeight, params.Expiry,
|
||||
bestBlockHeight,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// depositBlocksUntilExpiry returns the remaining blocks until a deposit
|
||||
// expires. Unconfirmed deposits return the full CSV value because the timeout
|
||||
// has not started yet.
|
||||
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
|
||||
bestBlockHeight int64) int64 {
|
||||
|
||||
if confirmationHeight <= 0 {
|
||||
return int64(expiry)
|
||||
}
|
||||
|
||||
return confirmationHeight + int64(expiry) - bestBlockHeight
|
||||
}
|
||||
|
||||
// StaticOpenChannel initiates an open channel request using static address
|
||||
// deposits.
|
||||
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
|
||||
|
|
|
|||
86
loopd/swapclient_server_deposit_test.go
Normal file
86
loopd/swapclient_server_deposit_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
238
loopd/swapclient_server_staticaddr_test.go
Normal file
238
loopd/swapclient_server_staticaddr_test.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package loopd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btclog/v2"
|
||||
"github.com/lightninglabs/loop/looprpc"
|
||||
"github.com/lightninglabs/loop/staticaddr/address"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
"github.com/lightninglabs/loop/staticaddr/script"
|
||||
mock_lnd "github.com/lightninglabs/loop/test"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type staticAddrDepositStore struct {
|
||||
allDeposits []*deposit.Deposit
|
||||
byOutpoint map[string]*deposit.Deposit
|
||||
}
|
||||
|
||||
// CreateDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) CreateDeposit(context.Context,
|
||||
*deposit.Deposit) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) UpdateDeposit(context.Context,
|
||||
*deposit.Deposit) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) GetDeposit(context.Context,
|
||||
deposit.ID) (*deposit.Deposit, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// DepositForOutpoint returns the deposit for the requested outpoint.
|
||||
func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context,
|
||||
outpoint string) (*deposit.Deposit, error) {
|
||||
|
||||
if deposit, ok := s.byOutpoint[outpoint]; ok {
|
||||
return deposit, nil
|
||||
}
|
||||
|
||||
return nil, deposit.ErrDepositNotFound
|
||||
}
|
||||
|
||||
// AllDeposits returns all deposits seeded into the test store.
|
||||
func (s *staticAddrDepositStore) AllDeposits(context.Context) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
return s.allDeposits, nil
|
||||
}
|
||||
|
||||
type staticAddrTestAddressManager struct{}
|
||||
|
||||
func (s *staticAddrTestAddressManager) GetStaticAddressParameters(
|
||||
context.Context) (*script.Parameters, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *staticAddrTestAddressManager) GetStaticAddress(
|
||||
context.Context) (*script.StaticAddress, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *staticAddrTestAddressManager) ListUnspent(context.Context,
|
||||
int32, int32) ([]*lnwallet.Utxo, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *staticAddrTestAddressManager) GetTaprootAddress(
|
||||
*btcec.PublicKey, *btcec.PublicKey, int64) (*btcutil.AddressTaproot,
|
||||
error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// newTestDepositManager creates a deposit manager backed by seeded deposits.
|
||||
func newTestDepositManager(
|
||||
deposits ...*deposit.Deposit) *deposit.Manager {
|
||||
|
||||
byOutpoint := make(map[string]*deposit.Deposit, len(deposits))
|
||||
for _, deposit := range deposits {
|
||||
byOutpoint[deposit.OutPoint.String()] = deposit
|
||||
}
|
||||
|
||||
return deposit.NewManager(&deposit.ManagerConfig{
|
||||
AddressManager: &staticAddrTestAddressManager{},
|
||||
Store: &staticAddrDepositStore{
|
||||
allDeposits: deposits,
|
||||
byOutpoint: byOutpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// newTestStaticAddressContext creates static address test dependencies.
|
||||
func newTestStaticAddressContext(t *testing.T) (*address.Manager,
|
||||
*mock_lnd.LndMockServices) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
mock := mock_lnd.NewMockLnd()
|
||||
_, client := mock_lnd.CreateKey(1)
|
||||
_, server := mock_lnd.CreateKey(2)
|
||||
|
||||
addrStore := &mockAddressStore{
|
||||
params: []*script.Parameters{{
|
||||
ClientPubkey: client,
|
||||
ServerPubkey: server,
|
||||
Expiry: 10,
|
||||
PkScript: []byte("pkscript"),
|
||||
}},
|
||||
}
|
||||
|
||||
addrMgr, err := address.NewManager(&address.ManagerConfig{
|
||||
Store: addrStore,
|
||||
WalletKit: mock.WalletKit,
|
||||
ChainParams: mock.ChainParams,
|
||||
}, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
return addrMgr, mock
|
||||
}
|
||||
|
||||
// TestListStaticAddressDepositsReturnsVisibleDeposits verifies normal deposit
|
||||
// listings include visible deposit records.
|
||||
func TestListStaticAddressDepositsReturnsVisibleDeposits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
available := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{2},
|
||||
Index: 2,
|
||||
},
|
||||
}
|
||||
available.SetState(deposit.Deposited)
|
||||
|
||||
addrMgr, lnd := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(available),
|
||||
staticAddressManager: addrMgr,
|
||||
lnd: &lnd.LndServices,
|
||||
}
|
||||
|
||||
resp, err := server.ListStaticAddressDeposits(
|
||||
context.Background(), &looprpc.ListStaticAddressDepositsRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.FilteredDeposits, 1)
|
||||
require.Equal(
|
||||
t, available.OutPoint.String(),
|
||||
resp.FilteredDeposits[0].Outpoint,
|
||||
)
|
||||
}
|
||||
|
||||
// TestGetStaticAddressSummaryTotalsDeposits verifies visible deposits are
|
||||
// included in static address summary totals.
|
||||
func TestGetStaticAddressSummaryTotalsDeposits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{4},
|
||||
Index: 4,
|
||||
},
|
||||
Value: btcutil.Amount(2_000),
|
||||
ConfirmationHeight: 0,
|
||||
}
|
||||
unconfirmed.SetState(deposit.Deposited)
|
||||
|
||||
confirmed := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{5},
|
||||
Index: 5,
|
||||
},
|
||||
Value: btcutil.Amount(3_000),
|
||||
ConfirmationHeight: 123,
|
||||
}
|
||||
confirmed.SetState(deposit.Deposited)
|
||||
|
||||
addrMgr, _ := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(
|
||||
unconfirmed, confirmed,
|
||||
),
|
||||
staticAddressManager: addrMgr,
|
||||
}
|
||||
|
||||
resp, err := server.GetStaticAddressSummary(
|
||||
context.Background(), &looprpc.StaticAddressSummaryRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, resp.TotalNumDeposits)
|
||||
require.EqualValues(t, 2_000, resp.ValueUnconfirmedSatoshis)
|
||||
require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis)
|
||||
}
|
||||
|
||||
// TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote
|
||||
// requests fail for selected deposits that are no longer available.
|
||||
func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) {
|
||||
t.Parallel()
|
||||
setLogger(btclog.Disabled)
|
||||
|
||||
locked := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{6},
|
||||
Index: 6,
|
||||
},
|
||||
Value: btcutil.Amount(5_000),
|
||||
}
|
||||
locked.SetState(deposit.LoopingIn)
|
||||
|
||||
addrMgr, lnd := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(locked),
|
||||
staticAddressManager: addrMgr,
|
||||
lnd: &lnd.LndServices,
|
||||
}
|
||||
|
||||
_, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{
|
||||
DepositOutpoints: []string{locked.OutPoint.String()},
|
||||
})
|
||||
require.ErrorContains(t, err, "is not currently available")
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package loopd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -535,6 +536,14 @@ func (s *mockStaticAddressLoopInStore) IsStored(_ context.Context,
|
|||
return false, nil
|
||||
}
|
||||
|
||||
// RecordStaticAddressRiskDecision satisfies the static loop-in store interface.
|
||||
func (s *mockStaticAddressLoopInStore) RecordStaticAddressRiskDecision(
|
||||
_ context.Context, _ lntypes.Hash,
|
||||
_ loopin.ConfirmationRiskDecision) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLoopInByHash returns the configured loop-in with the given hash.
|
||||
func (s *mockStaticAddressLoopInStore) GetLoopInByHash(_ context.Context,
|
||||
swapHash lntypes.Hash) (*loopin.StaticAddressLoopIn, error) {
|
||||
|
|
@ -1321,7 +1330,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
|
|||
if d, ok := s.byOutpoint[outpoint]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, nil
|
||||
return nil, deposit.ErrDepositNotFound
|
||||
}
|
||||
|
||||
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
|
||||
|
|
@ -1335,6 +1344,90 @@ func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
|
|||
return deposits, nil
|
||||
}
|
||||
|
||||
// listUnspentDepositManager backs ListUnspentDeposits tests without requiring
|
||||
// the full deposit manager event loop.
|
||||
type listUnspentDepositManager struct {
|
||||
byOutpoint map[string]*deposit.Deposit
|
||||
|
||||
ensureDepositsFreshCalls int
|
||||
onEnsureDepositsFresh func(*listUnspentDepositManager)
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) EnsureDepositsFresh(
|
||||
context.Context) error {
|
||||
|
||||
m.ensureDepositsFreshCalls++
|
||||
if m.onEnsureDepositsFresh != nil {
|
||||
m.onEnsureDepositsFresh(m)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) GetActiveDepositsInState(
|
||||
state fsm.StateType) ([]*deposit.Deposit, error) {
|
||||
|
||||
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
|
||||
for _, d := range m.byOutpoint {
|
||||
if !d.IsInState(state) {
|
||||
continue
|
||||
}
|
||||
|
||||
deposits = append(deposits, d)
|
||||
}
|
||||
|
||||
return deposits, nil
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) DepositsForOutpoints(
|
||||
_ context.Context, outpoints []string, ignoreUnknown bool) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
deposits := make([]*deposit.Deposit, 0, len(outpoints))
|
||||
seen := make(map[string]struct{}, len(outpoints))
|
||||
for i, outpoint := range outpoints {
|
||||
if _, ok := seen[outpoint]; ok {
|
||||
return nil, fmt.Errorf("duplicate outpoint %s "+
|
||||
"at index %d", outpoint, i)
|
||||
}
|
||||
seen[outpoint] = struct{}{}
|
||||
|
||||
d, ok := m.byOutpoint[outpoint]
|
||||
if !ok {
|
||||
if ignoreUnknown {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, deposit.ErrDepositNotFound
|
||||
}
|
||||
|
||||
deposits = append(deposits, d)
|
||||
}
|
||||
|
||||
return deposits, nil
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) GetVisibleDeposits(
|
||||
context.Context) ([]*deposit.Deposit, error) {
|
||||
|
||||
return m.allDeposits(), nil
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) GetAllDeposits(
|
||||
context.Context) ([]*deposit.Deposit, error) {
|
||||
|
||||
return m.allDeposits(), nil
|
||||
}
|
||||
|
||||
func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit {
|
||||
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
|
||||
for _, d := range m.byOutpoint {
|
||||
deposits = append(deposits, d)
|
||||
}
|
||||
|
||||
return deposits
|
||||
}
|
||||
|
||||
// TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits.
|
||||
func TestListUnspentDeposits(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
|
@ -1376,39 +1469,41 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
minConfs := int64(deposit.MinConfs)
|
||||
utxoBelow := makeUtxo(0, minConfs-1) // always included
|
||||
utxoAt := makeUtxo(1, minConfs) // included only if Deposited
|
||||
utxoAbove1 := makeUtxo(2, minConfs+1)
|
||||
utxoAbove2 := makeUtxo(3, minConfs+2)
|
||||
utxoUnknown := makeUtxo(0, 0)
|
||||
utxoDeposited := makeUtxo(1, 1)
|
||||
utxoWithdrawn := makeUtxo(2, 2)
|
||||
utxoLoopingIn := makeUtxo(3, 5)
|
||||
utxoConfirmedUnknown := makeUtxo(4, 3)
|
||||
|
||||
// Helper to build the deposit manager with specific states.
|
||||
buildDepositMgr := func(
|
||||
states map[wire.OutPoint]fsm.StateType) *deposit.Manager {
|
||||
states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager {
|
||||
|
||||
store := &mockDepositStore{
|
||||
depMgr := &listUnspentDepositManager{
|
||||
byOutpoint: make(map[string]*deposit.Deposit),
|
||||
}
|
||||
for op, state := range states {
|
||||
d := &deposit.Deposit{OutPoint: op}
|
||||
d.SetState(state)
|
||||
store.byOutpoint[op.String()] = d
|
||||
depMgr.byOutpoint[op.String()] = d
|
||||
}
|
||||
|
||||
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
|
||||
return depMgr
|
||||
}
|
||||
|
||||
// Include below-min-conf and >=min with Deposited; exclude others.
|
||||
t.Run("below min conf always, Deposited included, others excluded",
|
||||
// Only known Deposited records are available. Unknown deposits and
|
||||
// known non-Deposited states are excluded.
|
||||
t.Run("only known Deposited included",
|
||||
func(t *testing.T) {
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{
|
||||
utxoBelow, utxoAt, utxoAbove1, utxoAbove2,
|
||||
utxoUnknown, utxoDeposited, utxoWithdrawn,
|
||||
utxoLoopingIn,
|
||||
})
|
||||
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
||||
utxoAt.OutPoint: deposit.Deposited,
|
||||
utxoAbove1.OutPoint: deposit.Withdrawn,
|
||||
utxoAbove2.OutPoint: deposit.LoopingIn,
|
||||
utxoDeposited.OutPoint: deposit.Deposited,
|
||||
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
||||
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
||||
})
|
||||
|
||||
server := &swapClientServer{
|
||||
|
|
@ -1420,9 +1515,10 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
ctx, &looprpc.ListUnspentDepositsRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
||||
|
||||
// Expect utxoBelow and utxoAt only.
|
||||
require.Len(t, resp.Utxos, 2)
|
||||
// Expect the Deposited utxo only.
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
got := map[string]struct{}{}
|
||||
for _, u := range resp.Utxos {
|
||||
got[u.Outpoint] = struct{}{}
|
||||
|
|
@ -1430,25 +1526,23 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
// same across utxos.
|
||||
require.NotEmpty(t, u.StaticAddress)
|
||||
}
|
||||
_, ok1 := got[utxoBelow.OutPoint.String()]
|
||||
_, ok2 := got[utxoAt.OutPoint.String()]
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
_, ok := got[utxoDeposited.OutPoint.String()]
|
||||
require.True(t, ok)
|
||||
})
|
||||
|
||||
// Swap states, now include utxoBelow and utxoAbove1.
|
||||
t.Run("Deposited on >=min included; non-Deposited excluded",
|
||||
// Confirmation depth no longer changes availability; state does.
|
||||
t.Run("availability ignores conf depth once deposit state is known",
|
||||
func(t *testing.T) {
|
||||
mock.SetListUnspent(
|
||||
[]*lnwallet.Utxo{
|
||||
utxoBelow, utxoAt, utxoAbove1,
|
||||
utxoAbove2,
|
||||
utxoUnknown, utxoDeposited,
|
||||
utxoWithdrawn, utxoLoopingIn,
|
||||
})
|
||||
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
||||
utxoAt.OutPoint: deposit.Withdrawn,
|
||||
utxoAbove1.OutPoint: deposit.Deposited,
|
||||
utxoAbove2.OutPoint: deposit.Withdrawn,
|
||||
utxoDeposited.OutPoint: deposit.Deposited,
|
||||
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
||||
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
||||
})
|
||||
|
||||
server := &swapClientServer{
|
||||
|
|
@ -1460,26 +1554,32 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
ctx, &looprpc.ListUnspentDepositsRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
||||
|
||||
require.Len(t, resp.Utxos, 2)
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
got := map[string]struct{}{}
|
||||
for _, u := range resp.Utxos {
|
||||
got[u.Outpoint] = struct{}{}
|
||||
}
|
||||
_, ok1 := got[utxoBelow.OutPoint.String()]
|
||||
_, ok2 := got[utxoAbove1.OutPoint.String()]
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
_, ok := got[utxoDeposited.OutPoint.String()]
|
||||
require.True(t, ok)
|
||||
})
|
||||
|
||||
// Confirmed UTXO not present in store should be included.
|
||||
t.Run("confirmed utxo not in store is included", func(t *testing.T) {
|
||||
// Only return a confirmed UTXO from lnd and make sure the
|
||||
// deposit manager/store doesn't know about it.
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2})
|
||||
// A wallet-visible UTXO reconciled by EnsureDepositsFresh should be
|
||||
// returned in the same ListUnspentDeposits call.
|
||||
t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) {
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
|
||||
|
||||
// Empty store (no states for any outpoint).
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
|
||||
depMgr.onEnsureDepositsFresh = func(
|
||||
m *listUnspentDepositManager) {
|
||||
|
||||
d := &deposit.Deposit{
|
||||
OutPoint: utxoConfirmedUnknown.OutPoint,
|
||||
}
|
||||
d.SetState(deposit.Deposited)
|
||||
m.byOutpoint[d.OutPoint.String()] = d
|
||||
}
|
||||
|
||||
server := &swapClientServer{
|
||||
staticAddressManager: addrMgr,
|
||||
|
|
@ -1490,13 +1590,12 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
ctx, &looprpc.ListUnspentDepositsRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
||||
|
||||
// We expect the confirmed UTXO to be included even though it
|
||||
// doesn't exist in the store yet.
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
require.Equal(
|
||||
t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint,
|
||||
t, utxoConfirmedUnknown.OutPoint.String(),
|
||||
resp.Utxos[0].Outpoint,
|
||||
)
|
||||
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
-- confirmation_risk_decision records the server's confirmation-risk decision
|
||||
-- for a static address loop-in. Possible values are:
|
||||
-- - '': no decision has been received yet;
|
||||
-- - 'accepted': the server accepted waiting for the low-confirmation
|
||||
-- deposits, which starts or reconstructs the payment deadline;
|
||||
-- - 'rejected': the server stopped waiting for the low-confirmation deposits
|
||||
-- before paying the invoice.
|
||||
-- Once rejected, a later accepted update is ignored.
|
||||
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- confirmation_risk_decision_time records when loopd received and persisted
|
||||
-- the server's decision, so payment deadlines can be reconstructed after
|
||||
-- restart. Same-decision replays preserve the original timestamp; changing
|
||||
-- from accepted to rejected updates it.
|
||||
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,22 @@ SET
|
|||
WHERE
|
||||
swap_hash = $1;
|
||||
|
||||
-- name: RecordStaticAddressRiskDecision :exec
|
||||
UPDATE static_address_swaps
|
||||
SET
|
||||
confirmation_risk_decision = $2,
|
||||
confirmation_risk_decision_time = CASE
|
||||
WHEN confirmation_risk_decision = $2 THEN
|
||||
COALESCE(confirmation_risk_decision_time, $3)
|
||||
ELSE $3
|
||||
END
|
||||
WHERE
|
||||
swap_hash = $1
|
||||
AND NOT (
|
||||
confirmation_risk_decision = 'rejected'
|
||||
AND $2 = 'accepted'
|
||||
);
|
||||
|
||||
-- name: InsertStaticAddressMetaUpdate :exec
|
||||
INSERT INTO static_address_swap_updates (
|
||||
swap_hash,
|
||||
|
|
@ -147,7 +163,3 @@ WHERE
|
|||
d.swap_hash = $1;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]
|
|||
const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one
|
||||
SELECT
|
||||
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
|
||||
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
|
||||
FROM
|
||||
swaps
|
||||
|
|
@ -166,36 +166,38 @@ WHERE
|
|||
`
|
||||
|
||||
type GetStaticAddressLoopInSwapRow struct {
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
}
|
||||
|
||||
func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) {
|
||||
|
|
@ -225,6 +227,8 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
|
|||
&i.HtlcTimeoutSweepAddress,
|
||||
&i.SelectedAmount,
|
||||
&i.Fast,
|
||||
&i.ConfirmationRiskDecision,
|
||||
&i.ConfirmationRiskDecisionTime,
|
||||
&i.SwapHash_3,
|
||||
&i.SenderScriptPubkey,
|
||||
&i.ReceiverScriptPubkey,
|
||||
|
|
@ -239,7 +243,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
|
|||
const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many
|
||||
SELECT
|
||||
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
|
||||
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
|
||||
FROM
|
||||
swaps
|
||||
|
|
@ -263,36 +267,38 @@ ORDER BY
|
|||
`
|
||||
|
||||
type GetStaticAddressLoopInSwapsByStatesRow struct {
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
}
|
||||
|
||||
func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) {
|
||||
|
|
@ -328,6 +334,8 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
|
|||
&i.HtlcTimeoutSweepAddress,
|
||||
&i.SelectedAmount,
|
||||
&i.Fast,
|
||||
&i.ConfirmationRiskDecision,
|
||||
&i.ConfirmationRiskDecisionTime,
|
||||
&i.SwapHash_3,
|
||||
&i.SenderScriptPubkey,
|
||||
&i.ReceiverScriptPubkey,
|
||||
|
|
@ -482,6 +490,34 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe
|
|||
return err
|
||||
}
|
||||
|
||||
const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec
|
||||
UPDATE static_address_swaps
|
||||
SET
|
||||
confirmation_risk_decision = $2,
|
||||
confirmation_risk_decision_time = CASE
|
||||
WHEN confirmation_risk_decision = $2 THEN
|
||||
COALESCE(confirmation_risk_decision_time, $3)
|
||||
ELSE $3
|
||||
END
|
||||
WHERE
|
||||
swap_hash = $1
|
||||
AND NOT (
|
||||
confirmation_risk_decision = 'rejected'
|
||||
AND $2 = 'accepted'
|
||||
)
|
||||
`
|
||||
|
||||
type RecordStaticAddressRiskDecisionParams struct {
|
||||
SwapHash []byte
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
}
|
||||
|
||||
func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime)
|
||||
return err
|
||||
}
|
||||
|
||||
const swapHashForDepositID = `-- name: SwapHashForDepositID :one
|
||||
SELECT
|
||||
swap_hash
|
||||
|
|
|
|||
|
|
@ -26,6 +26,14 @@ const (
|
|||
// static loop in sweep requests.
|
||||
NotificationTypeStaticLoopInSweepRequest
|
||||
|
||||
// NotificationTypeStaticLoopInRiskAccepted is the notification type for
|
||||
// static loop in confirmation risk acceptance.
|
||||
NotificationTypeStaticLoopInRiskAccepted
|
||||
|
||||
// NotificationTypeStaticLoopInRiskRejected is the notification type for
|
||||
// static loop in confirmation risk rejection.
|
||||
NotificationTypeStaticLoopInRiskRejected
|
||||
|
||||
// NotificationTypeUnfinishedSwap is the notification type for unfinished
|
||||
// swap notifications.
|
||||
NotificationTypeUnfinishedSwap
|
||||
|
|
@ -80,6 +88,13 @@ type Config struct {
|
|||
// MaxQueuedNotifications is the maximum number of notifications that
|
||||
// can wait in each subscriber's delivery queue.
|
||||
MaxQueuedNotifications int
|
||||
|
||||
// PersistStaticLoopInRiskDecision durably records static loop-in
|
||||
// confirmation-risk decisions. If this fails, the notification is still
|
||||
// cached and forwarded so a later subscriber can process it after the swap
|
||||
// row exists.
|
||||
PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash,
|
||||
bool) error
|
||||
}
|
||||
|
||||
// Manager is a manager for notifications that the swap server sends to the
|
||||
|
|
@ -91,7 +106,26 @@ type Manager struct {
|
|||
|
||||
hasL402 bool
|
||||
|
||||
// subscribers holds active notification subscribers by notification
|
||||
// type. It is guarded by the Manager mutex.
|
||||
subscribers map[NotificationType][]subscriber
|
||||
|
||||
// staticLoopInRiskAccepted caches accepted risk decisions by swap hash
|
||||
// so a later matching subscriber can receive a previously delivered
|
||||
// server decision.
|
||||
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification
|
||||
|
||||
// staticLoopInRiskRejected caches rejected risk decisions by swap hash
|
||||
// so a later matching subscriber can receive a previously delivered
|
||||
// server decision.
|
||||
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification
|
||||
|
||||
// staticLoopInRiskPersisted records whether the cached risk decision for
|
||||
// a swap hash was durably persisted. Unpersisted decisions remain cached
|
||||
// after subscriber cancellation so they can be replayed.
|
||||
staticLoopInRiskPersisted map[lntypes.Hash]bool
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager.
|
||||
|
|
@ -107,12 +141,22 @@ func NewManager(cfg *Config) *Manager {
|
|||
return &Manager{
|
||||
cfg: cfg,
|
||||
subscribers: make(map[NotificationType][]subscriber),
|
||||
staticLoopInRiskAccepted: make(
|
||||
map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification,
|
||||
),
|
||||
staticLoopInRiskRejected: make(
|
||||
map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification,
|
||||
),
|
||||
staticLoopInRiskPersisted: make(map[lntypes.Hash]bool),
|
||||
}
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
subCtx context.Context
|
||||
recvChan any
|
||||
swapHash *lntypes.Hash
|
||||
enqueue func(any)
|
||||
}
|
||||
|
||||
|
|
@ -222,6 +266,19 @@ func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
|
|||
}
|
||||
}
|
||||
|
||||
// dropNotification sends a best-effort notification to a subscriber.
|
||||
func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T,
|
||||
description string) {
|
||||
|
||||
select {
|
||||
case recvChan <- ntfn:
|
||||
case <-sub.subCtx.Done():
|
||||
default:
|
||||
log.Debugf("Dropping %s notification for slow subscriber",
|
||||
description)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeReservations subscribes to the reservation notifications.
|
||||
func (m *Manager) SubscribeReservations(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerReservationNotification {
|
||||
|
|
@ -271,6 +328,68 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
|||
return notifChan
|
||||
}
|
||||
|
||||
func subscribeStaticLoopInRiskDecision[T any](m *Manager, ctx context.Context,
|
||||
swapHash lntypes.Hash, notifType NotificationType,
|
||||
notifications map[lntypes.Hash]T) <-chan T {
|
||||
|
||||
notifChan := make(chan T, 1)
|
||||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
swapHash: &swapHash,
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
m.subscribers[notifType] = append(m.subscribers[notifType], sub)
|
||||
if ntfn, ok := notifications[swapHash]; ok {
|
||||
notifChan <- ntfn
|
||||
if m.staticLoopInRiskPersisted[swapHash] {
|
||||
delete(notifications, swapHash)
|
||||
delete(m.staticLoopInRiskPersisted, swapHash)
|
||||
}
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
context.AfterFunc(ctx, func() {
|
||||
m.removeSubscriber(notifType, sub)
|
||||
m.Lock()
|
||||
if _, ok := notifications[swapHash]; ok &&
|
||||
m.staticLoopInRiskPersisted[swapHash] {
|
||||
|
||||
delete(notifications, swapHash)
|
||||
delete(m.staticLoopInRiskPersisted, swapHash)
|
||||
}
|
||||
m.Unlock()
|
||||
close(notifChan)
|
||||
})
|
||||
|
||||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
|
||||
// notifications.
|
||||
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
|
||||
swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
return subscribeStaticLoopInRiskDecision(
|
||||
m, ctx, swapHash, NotificationTypeStaticLoopInRiskAccepted,
|
||||
m.staticLoopInRiskAccepted,
|
||||
)
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
|
||||
// notifications.
|
||||
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
|
||||
swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
|
||||
|
||||
return subscribeStaticLoopInRiskDecision(
|
||||
m, ctx, swapHash, NotificationTypeStaticLoopInRiskRejected,
|
||||
m.staticLoopInRiskRejected,
|
||||
)
|
||||
}
|
||||
|
||||
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
|
||||
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
|
||||
|
|
@ -428,7 +547,7 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error {
|
|||
notification, err := notifStream.Recv()
|
||||
if err == nil && notification != nil {
|
||||
log.Tracef("Received notification: %v", notification)
|
||||
m.handleNotification(notification)
|
||||
m.handleNotification(ctx, notification)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -438,9 +557,73 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskDecisionName returns the log label for a risk decision.
|
||||
func staticLoopInRiskDecisionName(accepted bool) string {
|
||||
if accepted {
|
||||
return "accepted"
|
||||
}
|
||||
|
||||
return "rejected"
|
||||
}
|
||||
|
||||
// handleStaticLoopInRiskDecision persists, caches, and forwards a risk
|
||||
// decision notification to the matching subscriber.
|
||||
func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context,
|
||||
swapHashBytes []byte, accepted bool, notifType NotificationType,
|
||||
cacheDecision func(lntypes.Hash, bool),
|
||||
notifySubscriber func(subscriber)) {
|
||||
|
||||
decision := staticLoopInRiskDecisionName(accepted)
|
||||
persisted := m.cfg.PersistStaticLoopInRiskDecision == nil
|
||||
|
||||
var (
|
||||
swapHash lntypes.Hash
|
||||
hasSwapHash bool
|
||||
)
|
||||
if swapHashBytes != nil {
|
||||
hash, err := lntypes.MakeHash(swapHashBytes)
|
||||
if err != nil {
|
||||
log.Warnf("Received invalid static loop in risk "+
|
||||
"%s notification: %v", decision, err)
|
||||
} else {
|
||||
swapHash = hash
|
||||
hasSwapHash = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil {
|
||||
err := m.cfg.PersistStaticLoopInRiskDecision(
|
||||
ctx, swapHash, accepted,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("Unable to persist static loop in risk "+
|
||||
"%s notification: %v", decision, err)
|
||||
} else {
|
||||
persisted = true
|
||||
}
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
if hasSwapHash {
|
||||
cacheDecision(swapHash, persisted)
|
||||
}
|
||||
|
||||
for _, sub := range m.subscribers[notifType] {
|
||||
if !hasSwapHash || sub.swapHash == nil ||
|
||||
*sub.swapHash != swapHash {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
notifySubscriber(sub)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNotification handles an incoming notification from the server,
|
||||
// forwarding it to the appropriate subscribers.
|
||||
func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
||||
func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc.
|
||||
SubscribeNotificationsResponse) {
|
||||
|
||||
switch ntfn.Notification.(type) {
|
||||
|
|
@ -476,6 +659,62 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
|||
queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn)
|
||||
}
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
|
||||
// We'll forward the static loop in risk accepted notification to the
|
||||
// subscriber for the matching swap.
|
||||
riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
|
||||
var swapHashBytes []byte
|
||||
if riskAcceptedNtfn != nil {
|
||||
swapHashBytes = riskAcceptedNtfn.SwapHash
|
||||
}
|
||||
|
||||
m.handleStaticLoopInRiskDecision(
|
||||
ctx, swapHashBytes, true,
|
||||
NotificationTypeStaticLoopInRiskAccepted,
|
||||
func(swapHash lntypes.Hash, persisted bool) {
|
||||
m.staticLoopInRiskAccepted[swapHash] =
|
||||
riskAcceptedNtfn
|
||||
m.staticLoopInRiskPersisted[swapHash] = persisted
|
||||
delete(m.staticLoopInRiskRejected, swapHash)
|
||||
},
|
||||
func(sub subscriber) {
|
||||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification)
|
||||
dropNotification(
|
||||
sub, recvChan, riskAcceptedNtfn,
|
||||
"static loop in risk accepted",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll
|
||||
// We'll forward the static loop in risk rejected notification to the
|
||||
// subscriber for the matching swap.
|
||||
riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected()
|
||||
var swapHashBytes []byte
|
||||
if riskRejectedNtfn != nil {
|
||||
swapHashBytes = riskRejectedNtfn.SwapHash
|
||||
}
|
||||
|
||||
m.handleStaticLoopInRiskDecision(
|
||||
ctx, swapHashBytes, false,
|
||||
NotificationTypeStaticLoopInRiskRejected,
|
||||
func(swapHash lntypes.Hash, persisted bool) {
|
||||
m.staticLoopInRiskRejected[swapHash] =
|
||||
riskRejectedNtfn
|
||||
m.staticLoopInRiskPersisted[swapHash] = persisted
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
},
|
||||
func(sub subscriber) {
|
||||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification)
|
||||
dropNotification(
|
||||
sub, recvChan, riskRejectedNtfn,
|
||||
"static loop in risk rejected",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
|
||||
// We'll forward the unfinished swap notification to all
|
||||
// subscribers.
|
||||
|
|
|
|||
|
|
@ -220,6 +220,89 @@ func staticLoopInSweepNotification(
|
|||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskAcceptedNotification builds a risk accepted notification.
|
||||
func staticLoopInRiskAcceptedNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskRejectedNotification builds a risk rejected notification.
|
||||
func staticLoopInRiskRejectedNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type staticLoopInRiskNotification interface {
|
||||
GetSwapHash() []byte
|
||||
}
|
||||
|
||||
// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout.
|
||||
func assertStaticLoopInRiskNotificationSwapScoped[
|
||||
T staticLoopInRiskNotification](t *testing.T,
|
||||
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
|
||||
notification func(lntypes.Hash) *swapserverrpc.
|
||||
SubscribeNotificationsResponse, label string,
|
||||
swapHashA, swapHashB lntypes.Hash) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChanA := subscribe(mgr, subCtx, swapHashA)
|
||||
subChanB := subscribe(mgr, subCtx, swapHashB)
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHashA))
|
||||
|
||||
select {
|
||||
case received := <-subChanA:
|
||||
require.Equal(t, swapHashA[:], received.GetSwapHash())
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("did not receive first swap risk %s notification",
|
||||
label)
|
||||
}
|
||||
|
||||
select {
|
||||
case received := <-subChanB:
|
||||
t.Fatalf("second swap received wrong notification: %x",
|
||||
received.GetSwapHash())
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHashB))
|
||||
|
||||
select {
|
||||
case received := <-subChanB:
|
||||
require.Equal(t, swapHashB[:], received.GetSwapHash())
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("did not receive second swap risk %s notification",
|
||||
label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_SlowReservationSubscriberDoesNotBlock tests that a reservation
|
||||
// subscriber with a full notification channel does not block delivery to other
|
||||
// subscribers. Reservation notifications are best-effort, so slow subscribers
|
||||
|
|
@ -238,7 +321,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
|
|||
fastChan := mgr.SubscribeReservations(fastCtx)
|
||||
|
||||
firstNotif := getTestNotification(testReservationId)
|
||||
mgr.handleNotification(firstNotif)
|
||||
mgr.handleNotification(t.Context(), firstNotif)
|
||||
|
||||
received := <-fastChan
|
||||
require.Equal(t, testReservationId, received.ReservationId)
|
||||
|
|
@ -246,7 +329,7 @@ func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
|
|||
secondNotif := getTestNotification(testReservationId2)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mgr.handleNotification(secondNotif)
|
||||
mgr.handleNotification(t.Context(), secondNotif)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
|
@ -345,7 +428,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
|
|||
subChan := mgr.SubscribeUnfinishedSwaps(subCtx)
|
||||
|
||||
swapHashA := lntypes.Hash{0x21, 0x22}
|
||||
mgr.handleNotification(unfinishedSwapNotification(swapHashA))
|
||||
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashA))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(subChan) == 1
|
||||
|
|
@ -354,7 +437,7 @@ func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
|
|||
swapHashB := lntypes.Hash{0x23, 0x24}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mgr.handleNotification(unfinishedSwapNotification(swapHashB))
|
||||
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashB))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
|
@ -426,11 +509,11 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T,
|
|||
|
||||
subChan := subscribe(mgr, subCtx)
|
||||
|
||||
mgr.handleNotification(notification(swapHashA))
|
||||
mgr.handleNotification(t.Context(), notification(swapHashA))
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mgr.handleNotification(notification(swapHashB))
|
||||
mgr.handleNotification(t.Context(), notification(swapHashB))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
|
@ -460,6 +543,345 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T,
|
|||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
|
||||
// forwards static loop in risk accepted notifications to subscribers.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
swapHash := lntypes.Hash{0x04, 0x05}
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are
|
||||
// handed to the durable callback before they are treated as delivered.
|
||||
func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type persistedDecision struct {
|
||||
swapHash lntypes.Hash
|
||||
accepted bool
|
||||
}
|
||||
|
||||
persisted := make(chan persistedDecision, 2)
|
||||
mgr := NewManager(&Config{
|
||||
PersistStaticLoopInRiskDecision: func(_ context.Context,
|
||||
swapHash lntypes.Hash, accepted bool) error {
|
||||
|
||||
persisted <- persistedDecision{
|
||||
swapHash: swapHash,
|
||||
accepted: accepted,
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
acceptedHash := lntypes.Hash{0x16, 0x17}
|
||||
rejectedHash := lntypes.Hash{0x18, 0x19}
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash),
|
||||
)
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskRejectedNotification(rejectedHash),
|
||||
)
|
||||
|
||||
select {
|
||||
case decision := <-persisted:
|
||||
require.Equal(t, acceptedHash, decision.swapHash)
|
||||
require.True(t, decision.accepted)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("accepted risk decision was not persisted")
|
||||
}
|
||||
|
||||
select {
|
||||
case decision := <-persisted:
|
||||
require.Equal(t, rejectedHash, decision.swapHash)
|
||||
require.False(t, decision.accepted)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("rejected risk decision was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an
|
||||
// early risk notification is still cached if the swap row does not exist yet.
|
||||
func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
swapHash := lntypes.Hash{0x1a, 0x1b}
|
||||
mgr := NewManager(&Config{
|
||||
PersistStaticLoopInRiskDecision: func(_ context.Context,
|
||||
_ lntypes.Hash, _ bool) error {
|
||||
|
||||
return errors.New("swap not stored yet")
|
||||
},
|
||||
})
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskAcceptedNotification(swapHash),
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk notification after persist failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel verifies that
|
||||
// a non-persisted risk decision remains replayable if the subscriber is canceled
|
||||
// before the FSM has a chance to process it.
|
||||
func TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
|
||||
t *testing.T) {
|
||||
|
||||
t.Parallel()
|
||||
|
||||
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
|
||||
t,
|
||||
(*Manager).SubscribeStaticLoopInRiskAccepted,
|
||||
staticLoopInRiskAcceptedNotification,
|
||||
)
|
||||
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
|
||||
t,
|
||||
(*Manager).SubscribeStaticLoopInRiskRejected,
|
||||
staticLoopInRiskRejectedNotification,
|
||||
)
|
||||
}
|
||||
|
||||
func assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel[
|
||||
T staticLoopInRiskNotification](t *testing.T,
|
||||
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
|
||||
notification func(lntypes.Hash) *swapserverrpc.
|
||||
SubscribeNotificationsResponse) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
swapHash := lntypes.Hash{0x2a, 0x2b}
|
||||
mgr := NewManager(&Config{
|
||||
PersistStaticLoopInRiskDecision: func(_ context.Context,
|
||||
_ lntypes.Hash, _ bool) error {
|
||||
|
||||
return errors.New("swap not stored yet")
|
||||
},
|
||||
})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
subChan := subscribe(mgr, subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHash))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return len(subChan) == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
subCancel()
|
||||
|
||||
select {
|
||||
case <-subChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("risk decision notification was not delivered before " +
|
||||
"cancel")
|
||||
}
|
||||
|
||||
select {
|
||||
case _, ok := <-subChan:
|
||||
require.False(t, ok)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("risk decision subscription did not close after cancel")
|
||||
}
|
||||
|
||||
replayCtx, replayCancel := context.WithCancel(t.Context())
|
||||
defer replayCancel()
|
||||
|
||||
replayChan := subscribe(mgr, replayCtx, swapHash)
|
||||
select {
|
||||
case received := <-replayChan:
|
||||
require.Equal(t, swapHash[:], received.GetSwapHash())
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cached risk decision was lost after subscriber " +
|
||||
"cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a
|
||||
// notification for one swap does not occupy another swap's subscriber channel.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertStaticLoopInRiskNotificationSwapScoped(
|
||||
t, func(m *Manager, ctx context.Context,
|
||||
swapHash lntypes.Hash) <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash)
|
||||
}, staticLoopInRiskAcceptedNotification, "accepted",
|
||||
lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07},
|
||||
)
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
|
||||
// replays a risk accepted notification that arrives before the swap-specific
|
||||
// subscriber is registered.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
swapHash := lntypes.Hash{0x06, 0x07}
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager
|
||||
// forwards static loop in risk rejected notifications to subscribers.
|
||||
func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
swapHash := lntypes.Hash{0x08, 0x09}
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive risk rejected notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a
|
||||
// notification for one swap does not occupy another swap's subscriber channel.
|
||||
func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertStaticLoopInRiskNotificationSwapScoped(
|
||||
t, func(m *Manager, ctx context.Context,
|
||||
swapHash lntypes.Hash) <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification {
|
||||
|
||||
return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash)
|
||||
}, staticLoopInRiskRejectedNotification, "rejected",
|
||||
lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b},
|
||||
)
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager
|
||||
// replays a risk rejected notification that arrives before the swap-specific
|
||||
// subscriber is registered.
|
||||
func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
swapHash := lntypes.Hash{0x0a, 0x0b}
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk rejected notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_Backoff verifies that repeated failures in
|
||||
// subscribeNotifications cause the Manager to space out subscription attempts
|
||||
// via a predictable incremental backoff.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ type Deposit struct {
|
|||
Value btcutil.Amount
|
||||
|
||||
// ConfirmationHeight is the absolute height at which the deposit was
|
||||
// first confirmed.
|
||||
// first confirmed. A value of zero means the deposit is still
|
||||
// unconfirmed.
|
||||
ConfirmationHeight int64
|
||||
|
||||
// TimeOutSweepPkScript is the pk script that is used to sweep the
|
||||
|
|
@ -91,6 +92,10 @@ func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool {
|
|||
d.Lock()
|
||||
defer d.Unlock()
|
||||
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentHeight >= uint32(d.ConfirmationHeight)+expiry
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +132,12 @@ func (d *Deposit) isInStateNoLock(state fsm.StateType) bool {
|
|||
return d.state == state
|
||||
}
|
||||
|
||||
// IsInStateNoLock returns whether the deposit is in the given state without
|
||||
// acquiring the deposit lock.
|
||||
func (d *Deposit) IsInStateNoLock(state fsm.StateType) bool {
|
||||
return d.isInStateNoLock(state)
|
||||
}
|
||||
|
||||
// GetConfirmationHeight returns the deposit confirmation height.
|
||||
func (d *Deposit) GetConfirmationHeight() int64 {
|
||||
d.Lock()
|
||||
|
|
|
|||
17
staticaddr/deposit/deposit_test.go
Normal file
17
staticaddr/deposit/deposit_test.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDepositIsExpiredUnconfirmed verifies that unconfirmed deposits do not
|
||||
// expire because their CSV timeout has not started yet.
|
||||
func TestDepositIsExpiredUnconfirmed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
d := &Deposit{}
|
||||
|
||||
require.False(t, d.IsExpired(1_000, 144))
|
||||
}
|
||||
|
|
@ -42,8 +42,8 @@ var (
|
|||
|
||||
// States.
|
||||
var (
|
||||
// Deposited signals that funds at a static address have reached the
|
||||
// confirmation height.
|
||||
// Deposited signals that funds at a static address have been detected
|
||||
// and are available to the client.
|
||||
Deposited = fsm.StateType("Deposited")
|
||||
|
||||
// Withdrawing signals that the withdrawal transaction has been
|
||||
|
|
@ -93,8 +93,8 @@ var (
|
|||
// Events.
|
||||
var (
|
||||
// OnStart is sent to the fsm once the deposit outpoint has been
|
||||
// sufficiently confirmed. It transitions the fsm into the Deposited
|
||||
// state from where we can trigger a withdrawal, a loopin or an expiry.
|
||||
// detected. It transitions the fsm into the Deposited state from where
|
||||
// we can trigger a withdrawal, a loopin or an expiry.
|
||||
OnStart = fsm.EventType("OnStart")
|
||||
|
||||
// OnWithdrawInitiated is sent to the fsm when a withdrawal has been
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
|
|
@ -17,9 +18,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// MinConfs is the minimum number of confirmations we require for a
|
||||
// deposit to be considered available for loop-ins, coop-spends and
|
||||
// timeouts.
|
||||
// MinConfs is the legacy minimum confirmation target deposits had to
|
||||
// reach before they were considered ready to be used for swaps.
|
||||
MinConfs = 6
|
||||
|
||||
// MaxConfs is unset since we don't require a max number of
|
||||
|
|
@ -86,6 +86,9 @@ type Manager struct {
|
|||
// been finalized. The manager will adjust its internal state and flush
|
||||
// finalized deposits from its memory.
|
||||
finalizedDepositChan chan wire.OutPoint
|
||||
|
||||
// currentHeight stores the currently best known block height.
|
||||
currentHeight atomic.Uint32
|
||||
}
|
||||
|
||||
// NewManager creates a new deposit manager.
|
||||
|
|
@ -107,6 +110,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
var startupHeight uint32
|
||||
select {
|
||||
case height := <-newBlockChan:
|
||||
startupHeight = uint32(height)
|
||||
m.currentHeight.Store(startupHeight)
|
||||
|
||||
case err = <-newBlockErrChan:
|
||||
return err
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Recover previous deposits and static address parameters from the DB.
|
||||
err = m.recoverDeposits(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -120,6 +136,14 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
err = m.reconcileDeposits(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("unable to reconcile deposits: %v", err)
|
||||
} else {
|
||||
// The startup height was consumed before recovered deposit FSMs
|
||||
// existed. Replay it so already-expired recovered deposits can act
|
||||
// immediately, but only after their wallet view is fresh.
|
||||
err = m.notifyActiveDeposits(ctx, startupHeight)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Start the deposit notifier.
|
||||
|
|
@ -132,7 +156,15 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
for {
|
||||
select {
|
||||
case height := <-newBlockChan:
|
||||
err := m.notifyActiveDeposits(ctx, uint32(height))
|
||||
m.currentHeight.Store(uint32(height))
|
||||
|
||||
err := m.reconcileDeposits(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("unable to reconcile deposits: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.notifyActiveDeposits(ctx, uint32(height))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -226,8 +258,10 @@ func (m *Manager) recoverDeposits(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// pollDeposits polls new deposits to our static address and notifies the
|
||||
// manager's event loop about them.
|
||||
// pollDeposits periodically polls for new deposits to our static address. This
|
||||
// complements the block-driven reconciliation in the main event loop: while new
|
||||
// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker
|
||||
// here catches deposits that appear in the mempool between blocks.
|
||||
func (m *Manager) pollDeposits(ctx context.Context) {
|
||||
log.Debugf("Waiting for new static address deposits...")
|
||||
|
||||
|
|
@ -250,6 +284,15 @@ func (m *Manager) pollDeposits(ctx context.Context) {
|
|||
}()
|
||||
}
|
||||
|
||||
// EnsureDepositsFresh reconciles the cached active deposit set with lnd's
|
||||
// current wallet view. Spending paths call this before selecting deposits so
|
||||
// stale persisted records are not treated as live funds. This can happen when
|
||||
// an unconfirmed funding transaction is replaced, a confirmed deposit is
|
||||
// reorged out, or the output was spent outside the active manager path.
|
||||
func (m *Manager) EnsureDepositsFresh(ctx context.Context) error {
|
||||
return m.reconcileDeposits(ctx)
|
||||
}
|
||||
|
||||
// reconcileDeposits fetches all spends to our static addresses from our lnd
|
||||
// wallet and matches it against the deposits in our memory that we've seen so
|
||||
// far. It picks the newly identified deposits and starts a state machine per
|
||||
|
|
@ -261,12 +304,24 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
|||
log.Tracef("Reconciling new deposits...")
|
||||
|
||||
utxos, err := m.cfg.AddressManager.ListUnspent(
|
||||
ctx, MinConfs, MaxConfs,
|
||||
ctx, 0, MaxConfs,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to list new deposits: %w", err)
|
||||
}
|
||||
|
||||
currentHeight := m.currentHeight.Load()
|
||||
err = m.updateDepositConfirmations(ctx, utxos, currentHeight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to update deposit "+
|
||||
"confirmations: %w", err)
|
||||
}
|
||||
|
||||
err = m.syncActiveDeposits(ctx, utxos)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to sync active deposits: %w", err)
|
||||
}
|
||||
|
||||
newDeposits := m.filterNewDeposits(utxos)
|
||||
if len(newDeposits) == 0 {
|
||||
log.Tracef("No new deposits...")
|
||||
|
|
@ -274,7 +329,7 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
|||
}
|
||||
|
||||
for _, utxo := range newDeposits {
|
||||
deposit, err := m.createNewDeposit(ctx, utxo)
|
||||
deposit, err := m.createNewDeposit(ctx, utxo, currentHeight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retain new deposit: %w",
|
||||
err)
|
||||
|
|
@ -294,9 +349,11 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
|||
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
|
||||
// it in our database and manager memory.
|
||||
func (m *Manager) createNewDeposit(ctx context.Context,
|
||||
utxo *lnwallet.Utxo) (*Deposit, error) {
|
||||
utxo *lnwallet.Utxo, currentHeight uint32) (*Deposit, error) {
|
||||
|
||||
blockHeight, err := m.getBlockHeight(ctx, utxo)
|
||||
confirmationHeight, err := confirmationHeightForUtxo(
|
||||
currentHeight, utxo,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -324,7 +381,7 @@ func (m *Manager) createNewDeposit(ctx context.Context,
|
|||
state: Deposited,
|
||||
OutPoint: utxo.OutPoint,
|
||||
Value: utxo.Value,
|
||||
ConfirmationHeight: int64(blockHeight),
|
||||
ConfirmationHeight: confirmationHeight,
|
||||
TimeOutSweepPkScript: timeoutSweepPkScript,
|
||||
}
|
||||
|
||||
|
|
@ -340,37 +397,167 @@ func (m *Manager) createNewDeposit(ctx context.Context,
|
|||
return deposit, nil
|
||||
}
|
||||
|
||||
// getBlockHeight retrieves the block height of a given utxo.
|
||||
func (m *Manager) getBlockHeight(ctx context.Context,
|
||||
utxo *lnwallet.Utxo) (uint32, error) {
|
||||
// confirmationHeightForUtxo derives the first confirmation height of a wallet
|
||||
// UTXO from the manager's current block height. Unconfirmed UTXOs return 0.
|
||||
func confirmationHeightForUtxo(currentHeight uint32,
|
||||
utxo *lnwallet.Utxo) (int64, error) {
|
||||
|
||||
addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters(
|
||||
ctx,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("couldn't get confirmation height for "+
|
||||
"deposit, %w", err)
|
||||
if utxo.Confirmations <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
notifChan, errChan, err :=
|
||||
m.cfg.ChainNotifier.RegisterConfirmationsNtfn(
|
||||
ctx, &utxo.OutPoint.Hash, addressParams.PkScript,
|
||||
MinConfs, addressParams.InitiationHeight,
|
||||
if currentHeight == 0 {
|
||||
return 0, errors.New("current block height unavailable")
|
||||
}
|
||||
|
||||
firstConfirmationHeight := int64(currentHeight) - utxo.Confirmations + 1
|
||||
if firstConfirmationHeight <= 0 {
|
||||
return 0, fmt.Errorf("invalid confirmation height %d for %v "+
|
||||
"with current height %d and %d confirmations",
|
||||
firstConfirmationHeight, utxo.OutPoint, currentHeight,
|
||||
utxo.Confirmations)
|
||||
}
|
||||
|
||||
return firstConfirmationHeight, nil
|
||||
}
|
||||
|
||||
// updateDepositConfirmations syncs first confirmation heights for deposits that
|
||||
// are visible in lnd's wallet view.
|
||||
func (m *Manager) updateDepositConfirmations(ctx context.Context,
|
||||
utxos []*lnwallet.Utxo, currentHeight uint32) error {
|
||||
|
||||
for _, utxo := range utxos {
|
||||
m.mu.Lock()
|
||||
deposit, ok := m.deposits[utxo.OutPoint]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
err := func() error {
|
||||
deposit.Lock()
|
||||
defer deposit.Unlock()
|
||||
|
||||
previousConfirmationHeight := deposit.ConfirmationHeight
|
||||
|
||||
confirmationHeight, err := confirmationHeightForUtxo(
|
||||
currentHeight, utxo,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deposit.ConfirmationHeight == confirmationHeight {
|
||||
return nil
|
||||
}
|
||||
|
||||
deposit.ConfirmationHeight = confirmationHeight
|
||||
|
||||
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
|
||||
if err != nil {
|
||||
deposit.ConfirmationHeight = previousConfirmationHeight
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncActiveDeposits reconciles the live active set with lnd's current wallet
|
||||
// view. Known Deposited records that are visible but inactive become active
|
||||
// again, and active Deposited records that are no longer wallet-visible are
|
||||
// removed from the live set. The DB record is left untouched as historical
|
||||
// evidence that the outpoint was once detected.
|
||||
func (m *Manager) syncActiveDeposits(ctx context.Context,
|
||||
utxos []*lnwallet.Utxo) error {
|
||||
|
||||
currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos))
|
||||
for _, utxo := range utxos {
|
||||
currentUtxos[utxo.OutPoint] = struct{}{}
|
||||
}
|
||||
|
||||
type deactivatedDeposit struct {
|
||||
outpoint wire.OutPoint
|
||||
fsm *FSM
|
||||
}
|
||||
|
||||
toActivate := make([]*Deposit, 0, len(utxos))
|
||||
var toDeactivate []deactivatedDeposit
|
||||
func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
toDeactivate = make(
|
||||
[]deactivatedDeposit, 0, len(m.activeDeposits),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
for _, utxo := range utxos {
|
||||
deposit, ok := m.deposits[utxo.OutPoint]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, active := m.activeDeposits[utxo.OutPoint]; active {
|
||||
continue
|
||||
}
|
||||
|
||||
if !deposit.IsInState(Deposited) {
|
||||
continue
|
||||
}
|
||||
|
||||
toActivate = append(toActivate, deposit)
|
||||
}
|
||||
|
||||
for outpoint, fsm := range m.activeDeposits {
|
||||
if _, ok := currentUtxos[outpoint]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if fsm == nil || fsm.deposit == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !fsm.deposit.IsInState(Deposited) {
|
||||
continue
|
||||
}
|
||||
|
||||
delete(m.activeDeposits, outpoint)
|
||||
toDeactivate = append(toDeactivate, deactivatedDeposit{
|
||||
outpoint: outpoint,
|
||||
fsm: fsm,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
for _, deactivated := range toDeactivate {
|
||||
deactivated.fsm.Stop()
|
||||
|
||||
log.Infof("Removed vanished deposit %v from active set",
|
||||
deactivated.outpoint)
|
||||
}
|
||||
|
||||
select {
|
||||
case tx := <-notifChan:
|
||||
return tx.BlockHeight, nil
|
||||
for _, deposit := range toActivate {
|
||||
if !deposit.IsInState(Deposited) {
|
||||
continue
|
||||
}
|
||||
|
||||
case err := <-errChan:
|
||||
return 0, err
|
||||
err := m.startDepositFsm(ctx, deposit)
|
||||
if err != nil {
|
||||
m.removeActiveDeposit(deposit.OutPoint)
|
||||
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("Reactivated visible deposit %v", deposit.OutPoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterNewDeposits filters the given utxos for new deposits that we haven't
|
||||
|
|
@ -604,11 +791,41 @@ func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) {
|
|||
}
|
||||
}
|
||||
|
||||
// GetAllDeposits returns all active deposits.
|
||||
// GetAllDeposits returns all known deposits from the database.
|
||||
func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) {
|
||||
return m.cfg.Store.AllDeposits(ctx)
|
||||
}
|
||||
|
||||
// GetVisibleDeposits returns deposits that should be exposed through normal
|
||||
// user-facing views. The database can contain historical Deposited rows whose
|
||||
// outpoints are no longer present in lnd's current wallet view, for example
|
||||
// after replacement or reorg. Once the manager has recovered its live cache,
|
||||
// plain Deposited records are only visible while their outpoint is in the
|
||||
// active set.
|
||||
func (m *Manager) GetVisibleDeposits(ctx context.Context) ([]*Deposit, error) {
|
||||
deposits, err := m.cfg.Store.AllDeposits(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
liveCacheReady := len(m.deposits) > 0
|
||||
filtered := make([]*Deposit, 0, len(deposits))
|
||||
for _, d := range deposits {
|
||||
if liveCacheReady && d.IsInState(Deposited) {
|
||||
if _, ok := m.activeDeposits[d.OutPoint]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, d)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// UpdateDeposit overrides all fields of the deposit with given ID in the store.
|
||||
func (m *Manager) UpdateDeposit(ctx context.Context, d *Deposit) error {
|
||||
d.Lock()
|
||||
|
|
|
|||
39
staticaddr/deposit/manager_height_test.go
Normal file
39
staticaddr/deposit/manager_height_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConfirmationHeightForUtxo verifies confirmation heights are derived from
|
||||
// the current block height and wallet confirmation count.
|
||||
func TestConfirmationHeightForUtxo(t *testing.T) {
|
||||
t.Run("unconfirmed", func(t *testing.T) {
|
||||
height, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{})
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, height)
|
||||
})
|
||||
|
||||
t.Run("confirmed", func(t *testing.T) {
|
||||
height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{1},
|
||||
Index: 2,
|
||||
},
|
||||
Confirmations: 6,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 96, height)
|
||||
})
|
||||
|
||||
t.Run("invalid current height", func(t *testing.T) {
|
||||
_, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{
|
||||
Confirmations: 6,
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid confirmation height")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,14 +1,344 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/loop/fsm"
|
||||
"github.com/lightninglabs/loop/staticaddr/script"
|
||||
"github.com/lightninglabs/loop/staticaddr/version"
|
||||
"github.com/lightninglabs/loop/test"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestReconcileDepositsSerialized verifies reconciliation is serialized across
|
||||
// concurrent callers.
|
||||
func TestReconcileDepositsSerialized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
utxo := &lnwallet.Utxo{
|
||||
AddressType: lnwallet.TaprootPubkey,
|
||||
Value: btcutil.Amount(100_000),
|
||||
Confirmations: 0,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{1},
|
||||
Index: 1,
|
||||
},
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var createCalls atomic.Int32
|
||||
createEntered := make(chan struct{})
|
||||
releaseCreate := make(chan struct{})
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(mock.Arguments) {
|
||||
if createCalls.Add(1) == 1 {
|
||||
close(createEntered)
|
||||
}
|
||||
|
||||
<-releaseCreate
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- manager.reconcileDeposits(ctx)
|
||||
}()
|
||||
|
||||
<-createEntered
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- manager.reconcileDeposits(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(releaseCreate)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
var gotErrs []error
|
||||
for err := range errs {
|
||||
gotErrs = append(gotErrs, err)
|
||||
}
|
||||
|
||||
require.EqualValues(t, 1, createCalls.Load())
|
||||
require.Len(t, manager.deposits, 1)
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
require.Len(t, gotErrs, 2)
|
||||
|
||||
var errCount int
|
||||
for _, err := range gotErrs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
errCount++
|
||||
errMsg := err.Error()
|
||||
require.True(
|
||||
t,
|
||||
strings.Contains(
|
||||
errMsg, "unable to start new deposit FSM",
|
||||
) || strings.Contains(
|
||||
errMsg, "unable to sync active deposits",
|
||||
),
|
||||
"unexpected error: %v", err,
|
||||
)
|
||||
}
|
||||
require.Equal(t, 2, errCount)
|
||||
}
|
||||
|
||||
// TestReconcileConfirmedDepositUsesCurrentHeight verifies confirmation heights
|
||||
// are derived from the manager's current block height.
|
||||
func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
utxo := &lnwallet.Utxo{
|
||||
AddressType: lnwallet.TaprootPubkey,
|
||||
Value: btcutil.Amount(100_000),
|
||||
Confirmations: 3,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{8},
|
||||
Index: 1,
|
||||
},
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
createdDeposit := args.Get(1).(*Deposit)
|
||||
require.EqualValues(t, 98, createdDeposit.ConfirmationHeight)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
manager.currentHeight.Store(100)
|
||||
|
||||
err := manager.reconcileDeposits(ctx)
|
||||
require.ErrorContains(t, err, "unable to start new deposit FSM")
|
||||
}
|
||||
|
||||
// TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit
|
||||
// which remains wallet-visible but loses confirmations has its confirmation
|
||||
// height reset. This can happen if a confirmed transaction is reorged back into
|
||||
// the mempool.
|
||||
func TestUpdateDepositConfirmationsResetsReorgedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{7},
|
||||
Index: 2,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
ConfirmationHeight: 99,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: outpoint,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
require.Zero(t, updatedDeposit.ConfirmationHeight)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
|
||||
err := manager.updateDepositConfirmations(ctx, []*lnwallet.Utxo{utxo}, 0)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, deposit.ConfirmationHeight)
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestUpdateDepositConfirmationsRecomputesPositiveHeight verifies that a
|
||||
// deposit which is confirmed again at a different height after a reorg does
|
||||
// not retain its stale, positive confirmation height.
|
||||
func TestUpdateDepositConfirmationsRecomputesPositiveHeight(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{10},
|
||||
Index: 3,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
ConfirmationHeight: 100,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: outpoint,
|
||||
Confirmations: 2,
|
||||
}
|
||||
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
require.EqualValues(t, 109, updatedDeposit.ConfirmationHeight)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
|
||||
err := manager.updateDepositConfirmations(
|
||||
ctx, []*lnwallet.Utxo{utxo}, 110,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 109, deposit.ConfirmationHeight)
|
||||
mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit verifies that a
|
||||
// missing wallet outpoint is removed from the live active set without mutating
|
||||
// its historical DB state.
|
||||
func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{2},
|
||||
Index: 7,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{}, nil)
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: new(mockStore),
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
fsm := &FSM{
|
||||
deposit: deposit,
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
<-fsm.stopChan
|
||||
close(fsm.quitChan)
|
||||
}()
|
||||
manager.activeDeposits[outpoint] = fsm
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
select {
|
||||
case <-fsm.quitChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fsm did not stop after deposit vanished")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileDepositsDeactivatesVanishedConfirmedDeposit verifies that a
|
||||
// previously confirmed deposit is also removed from the live active set if it
|
||||
// vanishes from the wallet view.
|
||||
func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{9},
|
||||
Index: 4,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
ConfirmationHeight: 123,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{}, nil)
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: new(mockStore),
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
fsm := &FSM{
|
||||
deposit: deposit,
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
<-fsm.stopChan
|
||||
close(fsm.quitChan)
|
||||
}()
|
||||
manager.activeDeposits[outpoint] = fsm
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.EqualValues(t, 123, deposit.ConfirmationHeight)
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
select {
|
||||
case <-fsm.quitChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fsm did not stop after confirmed deposit vanished")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllOutpointsActiveDepositsRejectsDuplicateOutpoints verifies that a
|
||||
// duplicated selection is rejected before the manager tries to lock the same
|
||||
// deposit twice.
|
||||
|
|
@ -126,3 +456,280 @@ func TestLockDepositsAllowsReversedConcurrentRequests(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileDepositsReactivatesReappearedDeposit verifies that the same
|
||||
// outpoint can become active again if lnd reports it after a prior wallet-view
|
||||
// miss.
|
||||
func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{3},
|
||||
Index: 5,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
ConfirmationHeight: 77,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: outpoint,
|
||||
Value: deposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return(&script.Parameters{
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddress", mock.Anything,
|
||||
).Return((*script.StaticAddress)(nil), nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var updateStates []fsm.StateType
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
updateStates = append(updateStates, updatedDeposit.state)
|
||||
if updatedDeposit.isInStateNoLock(Deposited) {
|
||||
require.Zero(t, updatedDeposit.ConfirmationHeight)
|
||||
}
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
|
||||
// Reconciliation should reactivate the existing record instead of
|
||||
// creating a second deposit entry for the same outpoint.
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Zero(t, deposit.ConfirmationHeight)
|
||||
require.Len(t, manager.activeDeposits, 1)
|
||||
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
|
||||
}
|
||||
|
||||
// TestReconcileDepositsKeepsInactiveOnFSMStartFailure verifies that a failed
|
||||
// reactivation does not leave memory saying a deposit is active without an FSM.
|
||||
func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{11},
|
||||
Index: 5,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
ConfirmationHeight: 77,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: outpoint,
|
||||
Value: deposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
var (
|
||||
updateStates []fsm.StateType
|
||||
updateHeights []int64
|
||||
)
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
updateStates = append(updateStates, updatedDeposit.state)
|
||||
updateHeights = append(
|
||||
updateHeights, updatedDeposit.ConfirmationHeight,
|
||||
)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
|
||||
err := manager.reconcileDeposits(ctx)
|
||||
require.ErrorContains(t, err, "unable to sync active deposits")
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Zero(t, deposit.ConfirmationHeight)
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
require.Equal(t, []fsm.StateType{Deposited}, updateStates)
|
||||
require.EqualValues(t, []int64{0}, updateHeights)
|
||||
}
|
||||
|
||||
// TestReconcileDepositsDeactivatesBeforeActivationFailure verifies that a
|
||||
// failed reactivation of one visible deposit does not leave another vanished
|
||||
// deposit in the live active set.
|
||||
func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
visibleOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{21},
|
||||
Index: 5,
|
||||
}
|
||||
vanishedOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{22},
|
||||
Index: 6,
|
||||
}
|
||||
|
||||
visibleDeposit := &Deposit{
|
||||
OutPoint: visibleOutpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
}
|
||||
visibleDeposit.SetState(Deposited)
|
||||
|
||||
vanishedDeposit := &Deposit{
|
||||
OutPoint: vanishedOutpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
}
|
||||
vanishedDeposit.SetState(Deposited)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: visibleOutpoint,
|
||||
Value: visibleDeposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: new(mockStore),
|
||||
})
|
||||
manager.deposits[visibleOutpoint] = visibleDeposit
|
||||
manager.deposits[vanishedOutpoint] = vanishedDeposit
|
||||
|
||||
vanishedFsm := &FSM{
|
||||
deposit: vanishedDeposit,
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
<-vanishedFsm.stopChan
|
||||
close(vanishedFsm.quitChan)
|
||||
}()
|
||||
manager.activeDeposits[vanishedOutpoint] = vanishedFsm
|
||||
|
||||
err := manager.reconcileDeposits(ctx)
|
||||
require.ErrorContains(t, err, "unable to sync active deposits")
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
|
||||
select {
|
||||
case <-vanishedFsm.quitChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("vanished deposit fsm did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
|
||||
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
|
||||
// the outpoint selected by a loop-in.
|
||||
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
oldOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{4},
|
||||
Index: 8,
|
||||
}
|
||||
newOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{5},
|
||||
Index: 9,
|
||||
}
|
||||
|
||||
depositID, err := GetRandomDepositID()
|
||||
require.NoError(t, err)
|
||||
|
||||
deposit := &Deposit{
|
||||
ID: depositID,
|
||||
OutPoint: oldOutpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
}
|
||||
deposit.SetState(LoopingIn)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: newOutpoint,
|
||||
Value: deposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return(&script.Parameters{
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddress", mock.Anything,
|
||||
).Return((*script.StaticAddress)(nil), nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var createdDeposit *Deposit
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
createdDeposit = args.Get(1).(*Deposit)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
manager.deposits[oldOutpoint] = deposit
|
||||
fsm := &FSM{}
|
||||
manager.activeDeposits[oldOutpoint] = fsm
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
|
||||
require.Same(t, deposit, manager.deposits[oldOutpoint])
|
||||
require.Equal(t, oldOutpoint, deposit.OutPoint)
|
||||
require.Equal(t, LoopingIn, deposit.GetState())
|
||||
|
||||
replacement, ok := manager.deposits[newOutpoint]
|
||||
require.True(t, ok)
|
||||
require.Same(t, createdDeposit, replacement)
|
||||
require.NotEqual(t, depositID, replacement.ID)
|
||||
require.Equal(t, newOutpoint, replacement.OutPoint)
|
||||
require.Equal(t, Deposited, replacement.GetState())
|
||||
require.Zero(t, replacement.ConfirmationHeight)
|
||||
|
||||
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
|
||||
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
|
||||
|
||||
mockStore.AssertNotCalled(
|
||||
t, "UpdateDeposit", mock.Anything, mock.Anything,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package deposit
|
|||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -133,11 +134,30 @@ func (m *mockAddressManager) ListUnspent(ctx context.Context,
|
|||
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
|
||||
|
||||
args := m.Called(ctx, minConfs, maxConfs)
|
||||
if listUnspent, ok := args.Get(0).(func() []*lnwallet.Utxo); ok {
|
||||
return listUnspent(), args.Error(1)
|
||||
}
|
||||
|
||||
return args.Get(0).([]*lnwallet.Utxo),
|
||||
args.Error(1)
|
||||
}
|
||||
|
||||
// listUnspentOverride delegates all address-manager methods except
|
||||
// ListUnspent to another implementation.
|
||||
type listUnspentOverride struct {
|
||||
AddressManager
|
||||
|
||||
listUnspent func(context.Context, int32, int32) ([]*lnwallet.Utxo,
|
||||
error)
|
||||
}
|
||||
|
||||
// ListUnspent calls the override's ListUnspent implementation.
|
||||
func (l *listUnspentOverride) ListUnspent(ctx context.Context,
|
||||
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
|
||||
|
||||
return l.listUnspent(ctx, minConfs, maxConfs)
|
||||
}
|
||||
|
||||
func (m *mockAddressManager) GetTaprootAddress(clientPubkey,
|
||||
serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot,
|
||||
error) {
|
||||
|
|
@ -237,6 +257,10 @@ func TestManager(t *testing.T) {
|
|||
runErrChan <- testContext.manager.Run(ctx, initChan)
|
||||
}()
|
||||
|
||||
// Send an initial block so the manager can proceed past its startup
|
||||
// block wait.
|
||||
testContext.blockChan <- int32(defaultDepositConfirmations)
|
||||
|
||||
// Ensure that the manager has been initialized.
|
||||
select {
|
||||
case <-initChan:
|
||||
|
|
@ -307,6 +331,156 @@ func TestManager(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial
|
||||
// block epoch consumed during startup is delivered to recovered deposit FSMs.
|
||||
func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
testContext := newManagerTestContext(t)
|
||||
|
||||
initChan := make(chan struct{})
|
||||
runErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
runErrChan <- testContext.manager.Run(ctx, initChan)
|
||||
}()
|
||||
|
||||
// Send only the startup block at the recovered deposit's expiry height.
|
||||
testContext.blockChan <- int32(
|
||||
defaultDepositConfirmations + defaultExpiry,
|
||||
)
|
||||
|
||||
select {
|
||||
case <-initChan:
|
||||
|
||||
case err := <-runErrChan:
|
||||
require.NoError(t, err, "manager failed to start")
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("manager timed out starting")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testContext.mockLnd.SignOutputRawChannel:
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("did not receive sign request")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testContext.mockLnd.TxPublishChannel:
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("did not receive published expiry tx")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runErrChan:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("manager did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit
|
||||
// FSMs cannot make an expiry decision from stale confirmation data when wallet
|
||||
// reconciliation fails at startup or while processing a later block.
|
||||
func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
startupHeight int32
|
||||
blockHeight int32
|
||||
}{
|
||||
{
|
||||
name: "startup",
|
||||
startupHeight: int32(
|
||||
defaultDepositConfirmations + defaultExpiry,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "block",
|
||||
startupHeight: int32(defaultDepositConfirmations),
|
||||
blockHeight: int32(
|
||||
defaultDepositConfirmations + defaultExpiry,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
testContext := newManagerTestContext(t)
|
||||
baseAddressManager := testContext.mockAddressManager
|
||||
var listUnspentCalls int
|
||||
testContext.manager.cfg.AddressManager =
|
||||
&listUnspentOverride{
|
||||
AddressManager: baseAddressManager,
|
||||
listUnspent: func(ctx context.Context,
|
||||
minConfs, maxConfs int32) (
|
||||
[]*lnwallet.Utxo, error) {
|
||||
|
||||
listUnspentCalls++
|
||||
if testCase.blockHeight != 0 &&
|
||||
listUnspentCalls == 1 {
|
||||
|
||||
return baseAddressManager.ListUnspent(
|
||||
ctx, minConfs, maxConfs,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, errors.New(
|
||||
"injected reconciliation failure",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
initChan := make(chan struct{})
|
||||
runErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
runErrChan <- testContext.manager.Run(ctx, initChan)
|
||||
}()
|
||||
|
||||
testContext.blockChan <- testCase.startupHeight
|
||||
|
||||
select {
|
||||
case <-initChan:
|
||||
|
||||
case err := <-runErrChan:
|
||||
require.NoError(t, err, "manager failed to start")
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manager timed out starting")
|
||||
}
|
||||
|
||||
if testCase.blockHeight != 0 {
|
||||
testContext.blockChan <- testCase.blockHeight
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testContext.mockLnd.SignOutputRawChannel:
|
||||
t.Fatal("expiry sweep signed with stale deposit data")
|
||||
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runErrChan:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manager did not stop")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ManagerTestContext is a helper struct that contains all the necessary
|
||||
// components to test the reservation manager.
|
||||
type ManagerTestContext struct {
|
||||
|
|
@ -366,6 +540,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
|
|||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil)
|
||||
|
||||
var manager *Manager
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return(&script.Parameters{
|
||||
|
|
@ -374,7 +549,19 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
|
|||
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, mock.Anything, mock.Anything,
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
).Return(func() []*lnwallet.Utxo {
|
||||
currentUtxo := *utxo
|
||||
currentHeight := manager.currentHeight.Load()
|
||||
if currentHeight < defaultDepositConfirmations {
|
||||
currentUtxo.Confirmations = 0
|
||||
} else {
|
||||
currentUtxo.Confirmations = int64(
|
||||
currentHeight - defaultDepositConfirmations + 1,
|
||||
)
|
||||
}
|
||||
|
||||
return []*lnwallet.Utxo{¤tUtxo}
|
||||
}, nil)
|
||||
|
||||
// Define the expected return values for the mocks.
|
||||
mockChainNotifier.On(
|
||||
|
|
@ -394,7 +581,7 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext {
|
|||
Signer: mockLnd.Signer,
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
manager = NewManager(cfg)
|
||||
|
||||
testContext := &ManagerTestContext{
|
||||
manager: manager,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const (
|
|||
DefaultPaymentTimeoutSeconds = 60
|
||||
|
||||
defaultInvoiceCleanupTimeout = 5 * time.Second
|
||||
|
||||
monitorRetryDelay = time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -69,7 +71,10 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
return
|
||||
}
|
||||
|
||||
f.cancelSwapInvoice()
|
||||
if err := f.cancelSwapInvoice(); err != nil {
|
||||
f.Warnf("unable to clean up invoice for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
}
|
||||
}()
|
||||
|
||||
returnError := func(err error) fsm.EventType {
|
||||
|
|
@ -341,11 +346,12 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
return event
|
||||
}
|
||||
|
||||
// cancelSwapInvoice best-effort cancels the current swap invoice using a
|
||||
// detached timeout-limited context.
|
||||
func (f *FSM) cancelSwapInvoice() {
|
||||
if f.loopIn.SwapInvoice == "" {
|
||||
return
|
||||
// cancelSwapInvoice cancels the current swap invoice using a detached,
|
||||
// timeout-limited context. Callers that must not proceed while the invoice may
|
||||
// still be payable can use the returned error to retry.
|
||||
func (f *FSM) cancelSwapInvoice() error {
|
||||
if f.loopIn.SwapHash == (lntypes.Hash{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := context.WithTimeout(
|
||||
|
|
@ -354,10 +360,7 @@ func (f *FSM) cancelSwapInvoice() {
|
|||
defer cancel()
|
||||
|
||||
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
|
||||
if err != nil {
|
||||
f.Warnf("unable to cancel invoice for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
|
||||
|
|
@ -383,12 +386,167 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
|
|||
return fsm.NoOp, false
|
||||
|
||||
default:
|
||||
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
|
||||
"canceled", update.State, f.loopIn.SwapHash)
|
||||
return f.HandleError(err), true
|
||||
// An unknown state is not evidence that the invoice can no longer
|
||||
// settle. Keep monitoring rather than leaving the deposits available
|
||||
// for reuse.
|
||||
f.Warnf("unexpected invoice state %v for swap hash %v",
|
||||
update.State, f.loopIn.SwapHash)
|
||||
|
||||
return fsm.NoOp, false
|
||||
}
|
||||
}
|
||||
|
||||
// selectedDepositConfirmationHeights returns current confirmation heights for
|
||||
// the original deposit outpoints selected by this loop-in.
|
||||
func selectedDepositConfirmationHeights(
|
||||
loopIn *StaticAddressLoopIn) map[string]int64 {
|
||||
|
||||
confirmations := make(map[string]int64, len(loopIn.Deposits))
|
||||
outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints))
|
||||
for _, outpoint := range loopIn.DepositOutpoints {
|
||||
outpoints[outpoint] = struct{}{}
|
||||
}
|
||||
|
||||
for _, d := range loopIn.Deposits {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
outpoint := d.OutPoint.String()
|
||||
confirmationHeight := d.GetConfirmationHeight()
|
||||
|
||||
if _, ok := outpoints[outpoint]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
confirmations[outpoint] = confirmationHeight
|
||||
}
|
||||
|
||||
return confirmations
|
||||
}
|
||||
|
||||
// refreshSelectedDeposits reloads the loop-in's selected deposits from the
|
||||
// deposit manager/store so recovery does not rely on stale deposit snapshots.
|
||||
func (f *FSM) refreshSelectedDeposits(ctx context.Context) error {
|
||||
if f.cfg.DepositManager == nil || len(f.loopIn.DepositOutpoints) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := f.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to refresh deposit wallet view: %w", err)
|
||||
}
|
||||
|
||||
const ignoreUnknownOutpoints = false
|
||||
deposits, err := f.cfg.DepositManager.DepositsForOutpoints(
|
||||
ctx, f.loopIn.DepositOutpoints, ignoreUnknownOutpoints,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(deposits) != len(f.loopIn.DepositOutpoints) {
|
||||
return fmt.Errorf("expected %d selected deposits, got %d",
|
||||
len(f.loopIn.DepositOutpoints), len(deposits))
|
||||
}
|
||||
|
||||
f.loopIn.Deposits = deposits
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// legacyMinConfsReached returns true once every original deposit is confirmed
|
||||
// and the youngest original deposit has reached the legacy confirmation target.
|
||||
func legacyMinConfsReached(outpoints []string,
|
||||
confirmationHeights map[string]int64, currentHeight int32) bool {
|
||||
|
||||
if currentHeight <= 0 || len(outpoints) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
youngestConfirmation := int64(0)
|
||||
for _, outpoint := range outpoints {
|
||||
confirmationHeight, ok := confirmationHeights[outpoint]
|
||||
if !ok || confirmationHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if confirmationHeight > youngestConfirmation {
|
||||
youngestConfirmation = confirmationHeight
|
||||
}
|
||||
}
|
||||
|
||||
return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1
|
||||
}
|
||||
|
||||
// shouldStartLegacyConfirmationFallback reports whether the local MinConfs
|
||||
// payment deadline fallback should be armed at the current block height.
|
||||
//
|
||||
// The primary path starts the deadline from a server risk-accepted notification.
|
||||
// This fallback preserves the legacy client-side MinConfs behavior when no risk
|
||||
// decision has been observed locally: once every original deposit reaches
|
||||
// MinConfs, the client treats that as enough confirmation-risk clearance to
|
||||
// start the payment window. The selected deposits are refreshed first so
|
||||
// recovered swaps do not depend on stale in-memory deposit snapshots.
|
||||
func (f *FSM) shouldStartLegacyConfirmationFallback(ctx context.Context,
|
||||
currentHeight int32) bool {
|
||||
|
||||
err := f.refreshSelectedDeposits(ctx)
|
||||
if err != nil {
|
||||
f.Warnf("unable to refresh selected deposits for legacy "+
|
||||
"confirmation fallback: %v", err)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
depositConfirmationHeights := selectedDepositConfirmationHeights(
|
||||
f.loopIn,
|
||||
)
|
||||
|
||||
return legacyMinConfsReached(
|
||||
f.loopIn.DepositOutpoints, depositConfirmationHeights,
|
||||
currentHeight,
|
||||
)
|
||||
}
|
||||
|
||||
// originalDepositOutpointUnavailable checks the original selected deposit
|
||||
// outpoints against the chain backend's UTXO view.
|
||||
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
|
||||
bool, error) {
|
||||
|
||||
if f.cfg.TxOutChecker == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if len(f.loopIn.DepositOutpoints) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
outpoints := make([]wire.OutPoint, len(f.loopIn.DepositOutpoints))
|
||||
for i, outpointStr := range f.loopIn.DepositOutpoints {
|
||||
outpoint, err := wire.NewOutPointFromString(outpointStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
|
||||
outpointStr, err)
|
||||
}
|
||||
|
||||
outpoints[i] = *outpoint
|
||||
}
|
||||
|
||||
txOuts, err := f.cfg.TxOutChecker.GetTxOuts(ctx, outpoints)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to get txouts: %w", err)
|
||||
}
|
||||
|
||||
for _, outpoint := range outpoints {
|
||||
if txOuts[outpoint] == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SignHtlcTxAction is called if the htlc was initialized and the server
|
||||
// provided the necessary information to construct the htlc tx. We sign the htlc
|
||||
// tx and send the signatures to the server.
|
||||
|
|
@ -397,6 +555,21 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
|
|||
|
||||
var err error
|
||||
|
||||
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
|
||||
if err != nil {
|
||||
return f.HandleError(err)
|
||||
}
|
||||
if outpointUnavailable {
|
||||
err = errors.New("original deposit outpoint no longer available")
|
||||
f.Warnf("%v, canceling swap invoice", err)
|
||||
if cancelErr := f.cancelSwapInvoice(); cancelErr != nil {
|
||||
f.Warnf("unable to cancel invoice for swap %v: %v",
|
||||
f.loopIn.SwapHash, cancelErr)
|
||||
}
|
||||
|
||||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
f.loopIn.AddressParams, err =
|
||||
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
||||
|
||||
|
|
@ -618,6 +791,29 @@ func (f *FSM) cleanUpSessions(ctx context.Context,
|
|||
func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
retryMonitor := func(err error) fsm.EventType {
|
||||
if ctx.Err() != nil {
|
||||
return fsm.NoOp
|
||||
}
|
||||
|
||||
f.Errorf("monitoring failed: %v, retrying", err)
|
||||
|
||||
invoice, lookupErr := f.cfg.LndClient.LookupInvoice(
|
||||
ctx, f.loopIn.SwapHash,
|
||||
)
|
||||
if lookupErr == nil && invoice.State == invoices.ContractSettled {
|
||||
return OnPaymentReceived
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(monitorRetryDelay):
|
||||
return OnRecover
|
||||
|
||||
case <-ctx.Done():
|
||||
return fsm.NoOp
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the state of the swap invoice. If upon restart recovery,
|
||||
// we land here and observe that the invoice is already canceled, it can
|
||||
// only be the case where a user-provided payment timeout was hit, the
|
||||
|
|
@ -640,18 +836,20 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to subscribe to swap "+
|
||||
"invoice: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
htlc, err := f.loopIn.getHtlc(f.cfg.ChainParams)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("unable to get htlc: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
// Subscribe to htlc tx confirmation.
|
||||
reorgChan := make(chan struct{}, 1)
|
||||
// registerHtlcConf registers for the HTLC transaction confirmation using
|
||||
// the current reorg channel.
|
||||
registerHtlcConf := func() (chan *chainntnfs.TxConfirmation, chan error,
|
||||
error) {
|
||||
|
||||
|
|
@ -671,7 +869,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to monitor htlc tx confirmation: %w",
|
||||
err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
// Subscribe to new blocks.
|
||||
|
|
@ -684,9 +882,18 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
|
||||
err = fmt.Errorf("unable to subscribe to new blocks: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
// The watcher keeps notification normalization and timestamp restoration
|
||||
// outside of the swap-state handling below.
|
||||
riskWatcher := newConfirmationRiskWatcher(
|
||||
f.cfg, f.loopIn.SwapHash, f.Warnf,
|
||||
)
|
||||
riskUpdateChan, cancelRiskNotificationSubscriptions :=
|
||||
riskWatcher.subscribe(ctx)
|
||||
defer cancelRiskNotificationSubscriptions()
|
||||
|
||||
// Look up the current invoice state after registering subscriptions so
|
||||
// recovery can resume the payment deadline from the latest known state.
|
||||
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
|
||||
|
|
@ -695,51 +902,233 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return fsm.NoOp
|
||||
}
|
||||
|
||||
err = fmt.Errorf("unable to look up invoice by swap hash: %w",
|
||||
err)
|
||||
|
||||
return f.HandleError(err)
|
||||
// A failed lookup leaves the invoice state unknown. The active
|
||||
// subscription can still provide an authoritative update, so keep
|
||||
// monitoring and, most importantly, keep the deposits locked.
|
||||
f.Warnf("unable to look up invoice by swap hash: %v", err)
|
||||
invoice = &lndclient.Invoice{}
|
||||
}
|
||||
|
||||
// Create the swap payment timeout timer. If it runs out we cancel the
|
||||
// invoice, but keep monitoring the htlc confirmation.
|
||||
// If the invoice was canceled, e.g. before a restart, we don't need to
|
||||
// set a new deadline.
|
||||
var deadlineChan <-chan time.Time
|
||||
if invoice.State != invoices.ContractCanceled {
|
||||
// If the invoice is still live we set the timeout to the
|
||||
// remaining payment time. If too much time has elapsed, e.g.
|
||||
// after a restart, we cancel the invoice immediately and keep
|
||||
// monitoring the HTLC until it can no longer confirm.
|
||||
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
|
||||
// A settled invoice always takes precedence over a recovered risk
|
||||
// rejection or an elapsed payment deadline.
|
||||
if invoice.State == invoices.ContractSettled {
|
||||
return OnPaymentReceived
|
||||
}
|
||||
|
||||
// If the invoice isn't cancelled yet and the payment timeout
|
||||
// elapsed, we set the timeout to 0 to cancel the invoice and
|
||||
// unlock the deposits immediately. Otherwise, we start the
|
||||
// timer with the remaining seconds to timeout.
|
||||
timeout := time.Duration(0) * time.Second
|
||||
if remainingTimeSeconds > 0 {
|
||||
timeout = time.Duration(remainingTimeSeconds) *
|
||||
time.Second
|
||||
}
|
||||
|
||||
deadlineChan = time.NewTimer(timeout).C
|
||||
} else {
|
||||
invoiceCanceledForNonPayment := invoice.State == invoices.ContractCanceled
|
||||
if invoiceCanceledForNonPayment {
|
||||
// If the invoice was canceled previously we end our
|
||||
// subscription to invoice updates.
|
||||
cancelInvoiceSubscription()
|
||||
}
|
||||
|
||||
cancelInvoice := func() {
|
||||
f.Errorf("timeout waiting for invoice to be " +
|
||||
"paid, canceling invoice")
|
||||
// Create the swap payment timeout timer after the server confirms
|
||||
// confirmation risk was accepted. If a server does not support risk
|
||||
// notifications, fall back after the legacy deposit confirmation depth.
|
||||
var (
|
||||
deadlineChan <-chan time.Time
|
||||
deadlineTimer *time.Timer
|
||||
deadlineStarted bool
|
||||
)
|
||||
// Stop the payment deadline timer when leaving the monitor action.
|
||||
defer func() {
|
||||
if deadlineTimer != nil {
|
||||
deadlineTimer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// depositsInState reports whether all selected deposits are currently
|
||||
// in the requested state.
|
||||
depositsInState := func(state fsm.StateType) bool {
|
||||
if len(f.loopIn.Deposits) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, d := range f.loopIn.Deposits {
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !d.IsInState(state) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// startPaymentDeadline arms the server payment timeout from the decision
|
||||
// time when one is available.
|
||||
startPaymentDeadline := func(reason string, startedAt time.Time) {
|
||||
if deadlineStarted || invoice.State == invoices.ContractCanceled {
|
||||
return
|
||||
}
|
||||
|
||||
timeout := f.loopIn.PaymentTimeoutDuration()
|
||||
if !startedAt.IsZero() {
|
||||
timeout -= time.Since(startedAt)
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
}
|
||||
|
||||
f.Infof("starting payment deadline after %s", reason)
|
||||
deadlineTimer = time.NewTimer(timeout)
|
||||
deadlineChan = deadlineTimer.C
|
||||
deadlineStarted = true
|
||||
}
|
||||
|
||||
depositsLockedForHtlcTimeout := depositsInState(
|
||||
deposit.SweepHtlcTimeout,
|
||||
)
|
||||
|
||||
// transitionDepositsToHtlcTimeout locks deposits into timeout sweeping once
|
||||
// the HTLC is confirmed and the invoice cannot be paid.
|
||||
transitionDepositsToHtlcTimeout := func(reason string) error {
|
||||
if depositsLockedForHtlcTimeout ||
|
||||
depositsInState(deposit.SweepHtlcTimeout) {
|
||||
|
||||
depositsLockedForHtlcTimeout = true
|
||||
return nil
|
||||
}
|
||||
|
||||
depositsToTransition := make(
|
||||
[]*deposit.Deposit, 0, len(f.loopIn.Deposits),
|
||||
)
|
||||
for _, d := range f.loopIn.Deposits {
|
||||
if d != nil && d.IsInState(deposit.SweepHtlcTimeout) {
|
||||
continue
|
||||
}
|
||||
|
||||
depositsToTransition = append(depositsToTransition, d)
|
||||
}
|
||||
|
||||
transitionErr := f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, depositsToTransition,
|
||||
deposit.OnSweepingHtlcTimeout,
|
||||
deposit.SweepHtlcTimeout,
|
||||
)
|
||||
if transitionErr != nil {
|
||||
// WaitForState can report cancellation after the deposit FSMs
|
||||
// already reached the target state. Do not turn that shutdown
|
||||
// error into success: the monitor must return NoOp and remain
|
||||
// recoverable instead of advancing the loop-in FSM.
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// The transition can return an error after every deposit
|
||||
// already reached the target state. Treat that as
|
||||
// success, but never advance with a partial transition.
|
||||
if depositsInState(deposit.SweepHtlcTimeout) {
|
||||
depositsLockedForHtlcTimeout = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unable to transition deposits to the htlc "+
|
||||
"timeout sweeping state after %s: %w",
|
||||
reason, transitionErr)
|
||||
}
|
||||
if !depositsInState(deposit.SweepHtlcTimeout) {
|
||||
return fmt.Errorf("not all deposits reached the htlc timeout "+
|
||||
"sweeping state after %s", reason)
|
||||
}
|
||||
|
||||
depositsLockedForHtlcTimeout = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLegacyFallback starts the payment deadline once the old local
|
||||
// minimum-confirmation rule has been satisfied.
|
||||
startLegacyFallback := func(reason string, currentHeight int32) {
|
||||
if deadlineStarted || invoice.State == invoices.ContractCanceled ||
|
||||
f.loopIn.ConfirmationRiskDecision !=
|
||||
ConfirmationRiskDecisionNone {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if f.shouldStartLegacyConfirmationFallback(ctx, currentHeight) {
|
||||
decisionTime, ok := riskWatcher.durableDecisionTime(
|
||||
ctx, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
f.loopIn.ConfirmationRiskDecision =
|
||||
ConfirmationRiskDecisionAccepted
|
||||
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
||||
startPaymentDeadline(reason, decisionTime)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelInvoice only marks the invoice canceled after lnd acknowledges
|
||||
// the request or the lookup/subscription already observed that state.
|
||||
// Failures recover the monitor state without releasing deposits.
|
||||
cancelInvoice := func(reason string) (fsm.EventType, bool) {
|
||||
if invoice.State != invoices.ContractCanceled {
|
||||
f.Errorf("%s, canceling invoice", reason)
|
||||
if err := f.cancelSwapInvoice(); err != nil {
|
||||
return retryMonitor(err), false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the lndclient invoice subscription.
|
||||
cancelInvoiceSubscription()
|
||||
invoice.State = invoices.ContractCanceled
|
||||
invoiceCanceledForNonPayment = true
|
||||
|
||||
// Reuse the same helper as InitHtlcAction so timeout cleanup
|
||||
// follows the same detached-context path as early-init cleanup.
|
||||
f.cancelSwapInvoice()
|
||||
return fsm.NoOp, true
|
||||
}
|
||||
|
||||
// handleRiskRejected records a server rejection and only exits through
|
||||
// the generic error path once the invoice can no longer settle.
|
||||
handleRiskRejected := func(reason string,
|
||||
decisionTime time.Time) fsm.EventType {
|
||||
|
||||
f.loopIn.ConfirmationRiskDecision =
|
||||
ConfirmationRiskDecisionRejected
|
||||
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
||||
|
||||
event, canceled := cancelInvoice(
|
||||
"server rejected confirmation risk wait after " + reason,
|
||||
)
|
||||
if !canceled {
|
||||
return event
|
||||
}
|
||||
|
||||
return f.HandleError(fmt.Errorf(
|
||||
"server rejected confirmation risk wait after %s", reason,
|
||||
))
|
||||
}
|
||||
|
||||
switch f.loopIn.ConfirmationRiskDecision {
|
||||
case ConfirmationRiskDecisionAccepted:
|
||||
startPaymentDeadline(
|
||||
"recovered risk accepted notification",
|
||||
f.loopIn.ConfirmationRiskDecisionTime,
|
||||
)
|
||||
|
||||
case ConfirmationRiskDecisionRejected:
|
||||
decisionTime := riskWatcher.decisionTime(
|
||||
ctx, ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
|
||||
return handleRiskRejected(
|
||||
"recovered risk rejection", decisionTime,
|
||||
)
|
||||
}
|
||||
|
||||
info, err := f.cfg.LndClient.GetInfo(ctx)
|
||||
if err != nil {
|
||||
f.Warnf("unable to query current height for legacy confirmation "+
|
||||
"fallback: %v", err)
|
||||
} else {
|
||||
startLegacyFallback(
|
||||
"legacy confirmation fallback", int32(info.BlockHeight),
|
||||
)
|
||||
}
|
||||
|
||||
htlcConfirmed := false
|
||||
|
|
@ -749,6 +1138,14 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
f.Infof("htlc tx confirmed")
|
||||
|
||||
htlcConfirmed = true
|
||||
if invoiceCanceledForNonPayment {
|
||||
err = transitionDepositsToHtlcTimeout(
|
||||
"htlc confirmation after invoice cancellation",
|
||||
)
|
||||
if err != nil {
|
||||
return retryMonitor(err)
|
||||
}
|
||||
}
|
||||
|
||||
case err = <-htlcErrConfChan:
|
||||
if ctx.Err() != nil {
|
||||
|
|
@ -772,7 +1169,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to re-register for "+
|
||||
"htlc tx confirmation: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
case <-reorgChan:
|
||||
|
|
@ -790,23 +1187,68 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to monitor htlc tx "+
|
||||
"confirmation: %v", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
case <-deadlineChan:
|
||||
// If the server didn't pay the invoice on time, we
|
||||
// cancel the invoice and keep monitoring the htlc tx
|
||||
// confirmation. We also need to unlock the deposits to
|
||||
// re-enable them for loop-ins and withdrawals.
|
||||
cancelInvoice()
|
||||
deadlineChan = nil
|
||||
|
||||
// If the server didn't pay the invoice on time, we cancel
|
||||
// it and keep monitoring the htlc tx. Confirmed HTLC
|
||||
// deposits remain locked for timeout sweeping.
|
||||
event, canceled := cancelInvoice(
|
||||
"timeout waiting for invoice to be paid",
|
||||
)
|
||||
if !canceled {
|
||||
return event
|
||||
}
|
||||
if htlcConfirmed {
|
||||
err = transitionDepositsToHtlcTimeout(
|
||||
"payment deadline",
|
||||
)
|
||||
if err != nil {
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
f.Errorf("unable to unlock deposits after "+
|
||||
"payment deadline: %v", err)
|
||||
return retryMonitor(fmt.Errorf("unable to unlock deposits "+
|
||||
"after payment deadline: %w", err))
|
||||
}
|
||||
|
||||
case riskUpdate, ok := <-riskUpdateChan:
|
||||
if !ok {
|
||||
riskUpdateChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
decisionTime := riskWatcher.decisionTime(
|
||||
ctx, riskUpdate.decision,
|
||||
)
|
||||
f.loopIn.ConfirmationRiskDecision = riskUpdate.decision
|
||||
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
||||
|
||||
switch riskUpdate.decision {
|
||||
case ConfirmationRiskDecisionAccepted:
|
||||
startPaymentDeadline(
|
||||
riskUpdate.reason,
|
||||
f.loopIn.ConfirmationRiskDecisionTime,
|
||||
)
|
||||
|
||||
case ConfirmationRiskDecisionRejected:
|
||||
return handleRiskRejected(
|
||||
riskUpdate.reason, decisionTime,
|
||||
)
|
||||
}
|
||||
|
||||
case currentHeight := <-blockChan:
|
||||
startLegacyFallback(
|
||||
"legacy confirmation fallback", currentHeight,
|
||||
)
|
||||
|
||||
// If the htlc is confirmed but blockChan fires before
|
||||
// htlcConfChan, we would wrongfully assume that the
|
||||
// htlc tx was not confirmed which would lead to
|
||||
|
|
@ -824,10 +1266,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
|
||||
f.Infof("htlc timed out at block height %v",
|
||||
currentHeight)
|
||||
|
||||
// If the timeout path opened up we consider the swap
|
||||
// failed and cancel the invoice.
|
||||
cancelInvoice()
|
||||
event, canceled := cancelInvoice("htlc timed out")
|
||||
if !canceled {
|
||||
return event
|
||||
}
|
||||
|
||||
if !htlcConfirmed {
|
||||
f.Infof("swap timed out, htlc not confirmed")
|
||||
|
|
@ -835,24 +1277,23 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
// If the htlc hasn't confirmed but the timeout
|
||||
// path opened up, and we didn't receive the
|
||||
// swap payment, we consider the swap attempt to
|
||||
// be failed. We cancelled the invoice, but
|
||||
// don't need to unlock the deposits because
|
||||
// that happened when the payment deadline was
|
||||
// reached.
|
||||
// be failed. Now that the invoice is canceled and
|
||||
// the HTLC can no longer confirm, its deposits can be
|
||||
// made available again.
|
||||
err = f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
return retryMonitor(fmt.Errorf("unable to unlock "+
|
||||
"deposits after htlc timeout: %w", err))
|
||||
}
|
||||
|
||||
return OnSwapTimedOut
|
||||
}
|
||||
|
||||
// If the htlc has confirmed and the timeout path has
|
||||
// opened up we sweep the funds back to us.
|
||||
err = f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits,
|
||||
deposit.OnSweepingHtlcTimeout,
|
||||
deposit.SweepHtlcTimeout,
|
||||
)
|
||||
err = transitionDepositsToHtlcTimeout("htlc timeout")
|
||||
if err != nil {
|
||||
log.Errorf("unable to transition "+
|
||||
"deposits to the htlc timeout "+
|
||||
"sweeping state: %v", err)
|
||||
return retryMonitor(err)
|
||||
}
|
||||
|
||||
return OnSweepHtlcTimeout
|
||||
|
|
@ -864,10 +1305,16 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
|
||||
f.Errorf("block subscription error: %v", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return retryMonitor(err)
|
||||
|
||||
case update, ok := <-invoiceUpdateChan:
|
||||
if !ok {
|
||||
if !invoiceCanceledForNonPayment {
|
||||
return retryMonitor(errors.New(
|
||||
"invoice update subscription closed",
|
||||
))
|
||||
}
|
||||
|
||||
invoiceUpdateChan = nil
|
||||
continue
|
||||
}
|
||||
|
|
@ -876,13 +1323,30 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return event
|
||||
}
|
||||
|
||||
invoice.State = update.State
|
||||
if update.State == invoices.ContractCanceled {
|
||||
invoiceCanceledForNonPayment = true
|
||||
}
|
||||
|
||||
case err, ok := <-invoiceErrChan:
|
||||
if !ok {
|
||||
if !invoiceCanceledForNonPayment {
|
||||
return retryMonitor(errors.New(
|
||||
"invoice error subscription closed",
|
||||
))
|
||||
}
|
||||
|
||||
invoiceErrChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
f.Errorf("invoice subscription error: %v", err)
|
||||
if ctx.Err() != nil {
|
||||
return fsm.NoOp
|
||||
}
|
||||
|
||||
return retryMonitor(fmt.Errorf(
|
||||
"invoice subscription error: %w", err,
|
||||
))
|
||||
|
||||
case <-ctx.Done():
|
||||
return fsm.NoOp
|
||||
|
|
@ -1017,7 +1481,10 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context,
|
|||
func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
f.cancelSwapInvoice()
|
||||
if err := f.cancelSwapInvoice(); err != nil {
|
||||
f.Warnf("unable to cancel invoice for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
}
|
||||
|
||||
err := f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,11 @@ func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context,
|
|||
return nil, 0, false, ErrNoAutoloopCandidate
|
||||
}
|
||||
|
||||
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
|
||||
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
|
||||
deposit.Deposited,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -246,8 +246,9 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
|
|||
continue
|
||||
}
|
||||
|
||||
residualLife := confirmationHeight +
|
||||
int64(csvExpiry) - int64(blockHeight)
|
||||
residualLife := int64(blocksUntilDepositExpiry(
|
||||
uint32(confirmationHeight), blockHeight, csvExpiry,
|
||||
))
|
||||
|
||||
eligibleDeposits = append(
|
||||
eligibleDeposits, autoloopCandidateDeposit{
|
||||
|
|
|
|||
|
|
@ -80,6 +80,28 @@ func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits
|
||||
// are not treated as earlier-expiring than confirmed deposits. Their CSV timer
|
||||
// has not started yet, so a same-value confirmed deposit should win the expiry
|
||||
// tie-break.
|
||||
func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unconfirmed := makeDeposit(34, 0, 5_000, 0)
|
||||
confirmed := makeDeposit(35, 0, 5_000, 200)
|
||||
|
||||
deposits, err := selectNoChangeDeposits(
|
||||
5_000, 5_000, []*deposit.Deposit{
|
||||
unconfirmed, confirmed,
|
||||
}, 1_000, 100, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, []string{confirmed.OutPoint.String()},
|
||||
depositOutpoints(deposits),
|
||||
)
|
||||
}
|
||||
|
||||
// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
|
||||
// to understand directly than by inferring the step from a larger selector
|
||||
// behavior test.
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ type AddressManager interface {
|
|||
|
||||
// DepositManager handles the interaction of loop-ins with deposits.
|
||||
type DepositManager interface {
|
||||
// EnsureDepositsFresh reconciles active deposits with the wallet view.
|
||||
EnsureDepositsFresh(ctx context.Context) error
|
||||
|
||||
// GetAllDeposits returns all known deposits from the database store.
|
||||
GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error)
|
||||
|
||||
|
|
@ -88,6 +91,11 @@ type StaticAddressLoopInStore interface {
|
|||
// IsStored checks if the loop-in is already stored in the database.
|
||||
IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error)
|
||||
|
||||
// RecordStaticAddressRiskDecision persists the server's
|
||||
// confirmation-risk decision for the loop-in identified by swapHash.
|
||||
RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error
|
||||
|
||||
// GetLoopInByHash returns the loop-in swap with the given hash.
|
||||
GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) (
|
||||
*StaticAddressLoopIn, error)
|
||||
|
|
@ -121,4 +129,18 @@ type NotificationManager interface {
|
|||
// a sweep of a static loop in that has been finished.
|
||||
SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk
|
||||
// accepted notifications. These are sent by the server after the selected
|
||||
// deposits are accepted by confirmation risk tracking.
|
||||
SubscribeStaticLoopInRiskAccepted(
|
||||
ctx context.Context, swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
|
||||
|
||||
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk
|
||||
// rejected notifications. These are sent by the server if it aborts the
|
||||
// confirmation risk wait before payment.
|
||||
SubscribeStaticLoopInRiskRejected(
|
||||
ctx context.Context, swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ import (
|
|||
"github.com/lightningnetwork/lnd/zpay32"
|
||||
)
|
||||
|
||||
// ConfirmationRiskDecision records the server's decision on whether it accepts
|
||||
// waiting for low-confirmation deposits before paying a static loop-in invoice.
|
||||
type ConfirmationRiskDecision string
|
||||
|
||||
const (
|
||||
// ConfirmationRiskDecisionNone means no risk decision has been received.
|
||||
ConfirmationRiskDecisionNone ConfirmationRiskDecision = ""
|
||||
|
||||
// ConfirmationRiskDecisionAccepted means the server accepted waiting for
|
||||
// deposit confirmations and the payment deadline has started.
|
||||
ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted"
|
||||
|
||||
// ConfirmationRiskDecisionRejected means the server stopped waiting for
|
||||
// deposit confirmations before paying the invoice.
|
||||
ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected"
|
||||
)
|
||||
|
||||
// StaticAddressLoopIn represents the in-memory loop-in information.
|
||||
type StaticAddressLoopIn struct {
|
||||
// SwapHash is the hashed preimage of the swap invoice. It represents
|
||||
|
|
@ -107,6 +124,15 @@ type StaticAddressLoopIn struct {
|
|||
// LastUpdateTime is the timestamp of the latest persisted state update.
|
||||
LastUpdateTime time.Time
|
||||
|
||||
// ConfirmationRiskDecision records the server's persisted decision on
|
||||
// low-confirmation deposit risk.
|
||||
ConfirmationRiskDecision ConfirmationRiskDecision
|
||||
|
||||
// ConfirmationRiskDecisionTime is when loopd persisted the server risk
|
||||
// decision. It is used to reconstruct payment-deadline timeouts after
|
||||
// restart.
|
||||
ConfirmationRiskDecisionTime time.Time
|
||||
|
||||
// state is the current state of the swap.
|
||||
state fsm.StateType
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
|
|
@ -629,6 +630,11 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
selectedDeposits []*deposit.Deposit
|
||||
)
|
||||
|
||||
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w", err)
|
||||
}
|
||||
|
||||
// Determine which deposits to use for the loop-in swap. If none are
|
||||
// selected by the client, we will coin-select them based on the amount.
|
||||
switch {
|
||||
|
|
@ -845,11 +851,11 @@ func (m *Manager) activeDepositsForLoopIn(loopIn *StaticAddressLoopIn) (
|
|||
)
|
||||
}
|
||||
|
||||
// SelectDeposits sorts the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending order. It then selects the deposits that
|
||||
// are needed to cover the amount requested without leaving a dust change. It
|
||||
// returns an error if the sum of deposits minus dust is less than the requested
|
||||
// amount.
|
||||
// SelectDeposits sorts deposits by confirmation status first, then by amount in
|
||||
// descending order, then by blocks-until-expiry in ascending order. It then
|
||||
// selects the deposits that are needed to cover the amount requested without
|
||||
// leaving a dust change. It returns an error if the sum of deposits minus dust
|
||||
// is less than the requested amount.
|
||||
func SelectDeposits(targetAmount btcutil.Amount,
|
||||
unfilteredDeposits []*deposit.Deposit, csvExpiry uint32,
|
||||
blockHeight uint32) ([]*deposit.Deposit, error) {
|
||||
|
|
@ -871,14 +877,27 @@ func SelectDeposits(targetAmount btcutil.Amount,
|
|||
deposits = append(deposits, d)
|
||||
}
|
||||
|
||||
// Sort the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending order.
|
||||
// Sort confirmed deposits ahead of unconfirmed ones so auto-selection
|
||||
// prefers deposits the server can accept immediately. Within each group
|
||||
// we prefer larger deposits, then earlier expiries.
|
||||
sort.Slice(deposits, func(i, j int) bool {
|
||||
iConfirmationHeight := deposits[i].GetConfirmationHeight()
|
||||
jConfirmationHeight := deposits[j].GetConfirmationHeight()
|
||||
iConfirmed := iConfirmationHeight > 0
|
||||
jConfirmed := jConfirmationHeight > 0
|
||||
if iConfirmed != jConfirmed {
|
||||
return iConfirmed
|
||||
}
|
||||
|
||||
if deposits[i].Value == deposits[j].Value {
|
||||
iExp := uint32(deposits[i].GetConfirmationHeight()) +
|
||||
csvExpiry - blockHeight
|
||||
jExp := uint32(deposits[j].GetConfirmationHeight()) +
|
||||
csvExpiry - blockHeight
|
||||
iExp := blocksUntilDepositExpiry(
|
||||
uint32(iConfirmationHeight), blockHeight,
|
||||
csvExpiry,
|
||||
)
|
||||
jExp := blocksUntilDepositExpiry(
|
||||
uint32(jConfirmationHeight), blockHeight,
|
||||
csvExpiry,
|
||||
)
|
||||
|
||||
return iExp < jExp
|
||||
}
|
||||
|
|
@ -910,20 +929,33 @@ func SelectDeposits(targetAmount btcutil.Amount,
|
|||
// IsSwappable checks if a deposit is swappable. It returns true if the deposit
|
||||
// is not expired and the htlc is not too close to expiry.
|
||||
func IsSwappable(confirmationHeight, blockHeight, csvExpiry uint32) bool {
|
||||
// The deposit expiry height is the confirmation height plus the csv
|
||||
// expiry.
|
||||
depositExpiryHeight := confirmationHeight + csvExpiry
|
||||
|
||||
// The htlc expiry height is the current height plus the htlc
|
||||
// cltv delta.
|
||||
htlcExpiryHeight := blockHeight + DefaultLoopInOnChainCltvDelta
|
||||
|
||||
// Ensure that the deposit doesn't expire before the htlc.
|
||||
if depositExpiryHeight < htlcExpiryHeight+DepositHtlcDelta {
|
||||
return false
|
||||
if confirmationHeight == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
// The deposit expiry height is the confirmation height plus the csv
|
||||
// expiry.
|
||||
return blocksUntilDepositExpiry(
|
||||
confirmationHeight, blockHeight, csvExpiry,
|
||||
) >= DefaultLoopInOnChainCltvDelta+DepositHtlcDelta
|
||||
}
|
||||
|
||||
// blocksUntilDepositExpiry returns the remaining number of blocks until a
|
||||
// deposit expires. Unconfirmed deposits return MaxUint32 because their CSV has
|
||||
// not started yet.
|
||||
func blocksUntilDepositExpiry(confirmationHeight, blockHeight,
|
||||
csvExpiry uint32) uint32 {
|
||||
|
||||
if confirmationHeight == 0 {
|
||||
return math.MaxUint32
|
||||
}
|
||||
|
||||
depositExpiryHeight := confirmationHeight + csvExpiry
|
||||
if depositExpiryHeight <= blockHeight {
|
||||
return 0
|
||||
}
|
||||
|
||||
return depositExpiryHeight - blockHeight
|
||||
}
|
||||
|
||||
// DeduceSwapAmount calculates the swap amount based on the selected amount and
|
||||
|
|
|
|||
|
|
@ -81,6 +81,27 @@ func TestSelectDeposits(t *testing.T) {
|
|||
expected: []*deposit.Deposit{d3},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "prefer confirmed deposit over larger unconfirmed one",
|
||||
deposits: []*deposit.Deposit{
|
||||
{
|
||||
Value: 2_000_000,
|
||||
ConfirmationHeight: 0,
|
||||
},
|
||||
{
|
||||
Value: 1_500_000,
|
||||
ConfirmationHeight: 5_004,
|
||||
},
|
||||
},
|
||||
targetValue: 1_000_000,
|
||||
expected: []*deposit.Deposit{
|
||||
{
|
||||
Value: 1_500_000,
|
||||
ConfirmationHeight: 5_004,
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "single deposit insufficient by 1",
|
||||
deposits: []*deposit.Deposit{d1},
|
||||
|
|
@ -357,6 +378,12 @@ func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) {
|
|||
require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits)
|
||||
}
|
||||
|
||||
// TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered
|
||||
// swappable because its CSV timeout has not started yet.
|
||||
func TestIsSwappableUnconfirmed(t *testing.T) {
|
||||
require.True(t, IsSwappable(0, 5000, 1000))
|
||||
}
|
||||
|
||||
// mockDepositManager implements DepositManager for tests.
|
||||
type mockDepositManager struct {
|
||||
// activeDeposits is the set returned by GetActiveDepositsInState.
|
||||
|
|
@ -366,6 +393,10 @@ type mockDepositManager struct {
|
|||
byOutpoint map[string]*deposit.Deposit
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
|
|
@ -493,6 +524,13 @@ func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) {
|
|||
return false, nil
|
||||
}
|
||||
|
||||
// RecordStaticAddressRiskDecision implements Store for manager tests.
|
||||
func (s *mockStore) RecordStaticAddressRiskDecision(context.Context,
|
||||
lntypes.Hash, ConfirmationRiskDecision) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mockStore) GetLoopInByHash(_ context.Context,
|
||||
swapHash lntypes.Hash) (*StaticAddressLoopIn, error) {
|
||||
|
||||
|
|
|
|||
196
staticaddr/loopin/risk_watcher.go
Normal file
196
staticaddr/loopin/risk_watcher.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
)
|
||||
|
||||
// confirmationRiskUpdate is the normalized result of a server confirmation-risk
|
||||
// notification.
|
||||
type confirmationRiskUpdate struct {
|
||||
decision ConfirmationRiskDecision
|
||||
reason string
|
||||
}
|
||||
|
||||
// confirmationRiskWatcher normalizes static loop-in confirmation risk
|
||||
// notifications and restores the durable decision timestamp.
|
||||
type confirmationRiskWatcher struct {
|
||||
swapHash lntypes.Hash
|
||||
store StaticAddressLoopInStore
|
||||
notificationManager NotificationManager
|
||||
logWarnf func(string, ...any)
|
||||
}
|
||||
|
||||
// newConfirmationRiskWatcher creates a helper that handles confirmation-risk
|
||||
// notification plumbing for a single static loop-in swap.
|
||||
func newConfirmationRiskWatcher(cfg *Config, swapHash lntypes.Hash,
|
||||
warnf func(string, ...any)) *confirmationRiskWatcher {
|
||||
|
||||
return &confirmationRiskWatcher{
|
||||
swapHash: swapHash,
|
||||
store: cfg.Store,
|
||||
notificationManager: cfg.NotificationManager,
|
||||
logWarnf: warnf,
|
||||
}
|
||||
}
|
||||
|
||||
// warnf logs through the FSM-scoped logger when one is available.
|
||||
func (w *confirmationRiskWatcher) warnf(format string, args ...any) {
|
||||
if w.logWarnf != nil {
|
||||
w.logWarnf(format, args...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Warnf(format, args...)
|
||||
}
|
||||
|
||||
// subscribe subscribes to accepted and rejected confirmation-risk notifications
|
||||
// and emits normalized updates for the watcher's swap hash.
|
||||
func (w *confirmationRiskWatcher) subscribe(ctx context.Context) (
|
||||
<-chan confirmationRiskUpdate, func()) {
|
||||
|
||||
if w.notificationManager == nil {
|
||||
return nil, func() {}
|
||||
}
|
||||
|
||||
notificationCtx, cancel := context.WithCancel(ctx)
|
||||
riskAcceptedChan := w.notificationManager.SubscribeStaticLoopInRiskAccepted(
|
||||
notificationCtx, w.swapHash,
|
||||
)
|
||||
riskRejectedChan := w.notificationManager.SubscribeStaticLoopInRiskRejected(
|
||||
notificationCtx, w.swapHash,
|
||||
)
|
||||
|
||||
riskUpdates := make(chan confirmationRiskUpdate, 1)
|
||||
go func() {
|
||||
defer close(riskUpdates)
|
||||
|
||||
for {
|
||||
select {
|
||||
case riskAccepted, ok := <-riskAcceptedChan:
|
||||
if !ok {
|
||||
riskAcceptedChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if riskAccepted == nil || !bytes.Equal(
|
||||
riskAccepted.SwapHash, w.swapHash[:],
|
||||
) {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
update := confirmationRiskUpdate{
|
||||
decision: ConfirmationRiskDecisionAccepted,
|
||||
reason: "risk accepted notification",
|
||||
}
|
||||
select {
|
||||
case riskUpdates <- update:
|
||||
case <-notificationCtx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
case riskRejected, ok := <-riskRejectedChan:
|
||||
if !ok {
|
||||
riskRejectedChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if riskRejected == nil || !bytes.Equal(
|
||||
riskRejected.SwapHash, w.swapHash[:],
|
||||
) {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
update := confirmationRiskUpdate{
|
||||
decision: ConfirmationRiskDecisionRejected,
|
||||
reason: "risk rejection",
|
||||
}
|
||||
select {
|
||||
case riskUpdates <- update:
|
||||
case <-notificationCtx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
case <-notificationCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return riskUpdates, cancel
|
||||
}
|
||||
|
||||
// durableDecisionTime returns the durable decision timestamp, recording the
|
||||
// decision first if the notification was replayed before it could be persisted.
|
||||
// The bool is false when a configured store could not durably record or reload
|
||||
// the decision.
|
||||
func (w *confirmationRiskWatcher) durableDecisionTime(ctx context.Context,
|
||||
decision ConfirmationRiskDecision) (time.Time, bool) {
|
||||
|
||||
now := time.Now()
|
||||
if w.store == nil {
|
||||
return now, true
|
||||
}
|
||||
|
||||
storedLoopIn, err := w.store.GetLoopInByHash(ctx, w.swapHash)
|
||||
if err != nil {
|
||||
w.warnf("unable to reload persisted risk decision for swap %v: %v",
|
||||
w.swapHash, err)
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
if storedLoopIn == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
hasPersistedDecision :=
|
||||
storedLoopIn.ConfirmationRiskDecision == decision &&
|
||||
!storedLoopIn.ConfirmationRiskDecisionTime.IsZero()
|
||||
|
||||
if !hasPersistedDecision {
|
||||
err = w.store.RecordStaticAddressRiskDecision(
|
||||
ctx, w.swapHash, decision,
|
||||
)
|
||||
if err != nil {
|
||||
w.warnf("unable to persist replayed risk decision for "+
|
||||
"swap %v: %v", w.swapHash, err)
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
storedLoopIn, err = w.store.GetLoopInByHash(ctx, w.swapHash)
|
||||
if err != nil {
|
||||
w.warnf("unable to reload persisted risk decision for "+
|
||||
"swap %v: %v", w.swapHash, err)
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
if storedLoopIn == nil ||
|
||||
storedLoopIn.ConfirmationRiskDecision != decision ||
|
||||
storedLoopIn.ConfirmationRiskDecisionTime.IsZero() {
|
||||
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
return storedLoopIn.ConfirmationRiskDecisionTime, true
|
||||
}
|
||||
|
||||
// decisionTime retains the best-effort behavior used for server notifications.
|
||||
func (w *confirmationRiskWatcher) decisionTime(ctx context.Context,
|
||||
decision ConfirmationRiskDecision) time.Time {
|
||||
|
||||
decisionTime, ok := w.durableDecisionTime(ctx, decision)
|
||||
if !ok {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
return decisionTime
|
||||
}
|
||||
121
staticaddr/loopin/risk_watcher_test.go
Normal file
121
staticaddr/loopin/risk_watcher_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lightninglabs/loop/swapserverrpc"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConfirmationRiskWatcherSubscribeFiltersSwapHash verifies that the watcher
|
||||
// only emits normalized decisions for the swap it was created for.
|
||||
func TestConfirmationRiskWatcherSubscribeFiltersSwapHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
swapHash := lntypes.Hash{1, 2, 3}
|
||||
otherHash := lntypes.Hash{3, 2, 1}
|
||||
notificationMgr := &mockNotificationManager{
|
||||
riskAccepted: make(
|
||||
chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification, 1,
|
||||
),
|
||||
riskRejected: make(
|
||||
chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification, 1,
|
||||
),
|
||||
}
|
||||
|
||||
watcher := newConfirmationRiskWatcher(
|
||||
&Config{NotificationManager: notificationMgr}, swapHash,
|
||||
t.Logf,
|
||||
)
|
||||
updates, stop := watcher.subscribe(ctx)
|
||||
defer stop()
|
||||
|
||||
notificationMgr.riskAccepted <- &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: otherHash[:],
|
||||
}
|
||||
|
||||
select {
|
||||
case update := <-updates:
|
||||
t.Fatalf("received wrong-hash risk update: %v", update)
|
||||
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
notificationMgr.riskRejected <- &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
}
|
||||
|
||||
select {
|
||||
case update := <-updates:
|
||||
require.Equal(t, ConfirmationRiskDecisionRejected,
|
||||
update.decision)
|
||||
require.Equal(t, "risk rejection", update.reason)
|
||||
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("risk update not received: %v", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmationRiskWatcherDecisionTimeRestoration verifies that the watcher
|
||||
// preserves existing persisted decision timestamps and records missing ones.
|
||||
func TestConfirmationRiskWatcherDecisionTimeRestoration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := t.Context()
|
||||
swapHash := lntypes.Hash{4, 5, 6}
|
||||
decisionTime := time.Unix(123, 0).UTC()
|
||||
store := &recordingRiskStore{
|
||||
mockStore: &mockStore{
|
||||
loopIns: map[lntypes.Hash]*StaticAddressLoopIn{
|
||||
swapHash: {
|
||||
ConfirmationRiskDecision: ConfirmationRiskDecisionAccepted,
|
||||
ConfirmationRiskDecisionTime: decisionTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
decisions: make(chan ConfirmationRiskDecision, 1),
|
||||
}
|
||||
|
||||
watcher := newConfirmationRiskWatcher(&Config{Store: store}, swapHash,
|
||||
t.Logf)
|
||||
restoredTime := watcher.decisionTime(
|
||||
ctx, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
require.True(t, restoredTime.Equal(decisionTime))
|
||||
|
||||
select {
|
||||
case decision := <-store.decisions:
|
||||
t.Fatalf("persisted already-recorded decision: %v", decision)
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
store.loopIns[swapHash] = &StaticAddressLoopIn{}
|
||||
recordedTime := watcher.decisionTime(
|
||||
ctx, ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
require.False(t, recordedTime.IsZero())
|
||||
|
||||
select {
|
||||
case decision := <-store.decisions:
|
||||
require.Equal(t, ConfirmationRiskDecisionRejected, decision)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("missing risk decision was not persisted")
|
||||
}
|
||||
require.Equal(t, ConfirmationRiskDecisionRejected,
|
||||
store.loopIns[swapHash].ConfirmationRiskDecision)
|
||||
require.True(t, recordedTime.Equal(
|
||||
store.loopIns[swapHash].ConfirmationRiskDecisionTime,
|
||||
))
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ var (
|
|||
// ErrInvalidOutpoint is returned when an outpoint contains the outpoint
|
||||
// separator.
|
||||
ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator")
|
||||
|
||||
// ErrLoopInNotFound is returned when a loop-in swap is not stored.
|
||||
ErrLoopInNotFound = errors.New("static address loop-in not found")
|
||||
)
|
||||
|
||||
// Querier is the interface that contains all the queries generated by sqlc for
|
||||
|
|
@ -51,6 +54,11 @@ type Querier interface {
|
|||
UpdateStaticAddressLoopIn(ctx context.Context,
|
||||
arg sqlc.UpdateStaticAddressLoopInParams) error
|
||||
|
||||
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
|
||||
// decision for a loop-in swap.
|
||||
RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
arg sqlc.RecordStaticAddressRiskDecisionParams) error
|
||||
|
||||
// GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash.
|
||||
GetStaticAddressLoopInSwap(ctx context.Context,
|
||||
swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error)
|
||||
|
|
@ -361,6 +369,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
|
|||
)
|
||||
}
|
||||
|
||||
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
|
||||
// decision for a static address loop-in. The timestamp is written by the store
|
||||
// so recovery can reconstruct the remaining payment deadline from one durable
|
||||
// clock source.
|
||||
func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error {
|
||||
|
||||
if decision != ConfirmationRiskDecisionAccepted &&
|
||||
decision != ConfirmationRiskDecisionRejected {
|
||||
|
||||
return errors.New("unknown confirmation risk decision")
|
||||
}
|
||||
|
||||
params := sqlc.RecordStaticAddressRiskDecisionParams{
|
||||
SwapHash: swapHash[:],
|
||||
ConfirmationRiskDecision: string(decision),
|
||||
ConfirmationRiskDecisionTime: sql.NullTime{
|
||||
Time: s.clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
|
||||
func(q Querier) error {
|
||||
stored, err := q.IsStored(ctx, swapHash[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stored {
|
||||
return ErrLoopInNotFound
|
||||
}
|
||||
|
||||
return q.RecordStaticAddressRiskDecision(ctx, params)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context,
|
||||
updateAmounts map[lntypes.Hash]btcutil.Amount) error {
|
||||
|
||||
|
|
@ -584,6 +629,9 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
|
|||
DepositOutpoints: depositOutpoints,
|
||||
SelectedAmount: btcutil.Amount(swap.SelectedAmount),
|
||||
Fast: swap.Fast,
|
||||
ConfirmationRiskDecision: ConfirmationRiskDecision(
|
||||
swap.ConfirmationRiskDecision,
|
||||
),
|
||||
HtlcTxFeeRate: chainfee.SatPerKWeight(
|
||||
swap.HtlcTxFeeRateSatKw,
|
||||
),
|
||||
|
|
@ -591,6 +639,10 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
|
|||
HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash,
|
||||
Deposits: depositList,
|
||||
}
|
||||
if swap.ConfirmationRiskDecisionTime.Valid {
|
||||
loopIn.ConfirmationRiskDecisionTime =
|
||||
swap.ConfirmationRiskDecisionTime.Time
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
lastUpdate := updates[len(updates)-1]
|
||||
|
|
|
|||
|
|
@ -349,6 +349,83 @@ func TestCreateLoopIn(t *testing.T) {
|
|||
require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()},
|
||||
swap.DepositOutpoints)
|
||||
require.Equal(t, SignHtlcTx, swap.GetState())
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionNone,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
|
||||
decisionTime := time.Unix(123, 0).UTC()
|
||||
testClock.SetTime(decisionTime)
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionAccepted,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
|
||||
|
||||
// Replaying the same decision must retain its original deadline anchor.
|
||||
laterDecisionTime := decisionTime.Add(time.Hour)
|
||||
testClock.SetTime(laterDecisionTime)
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionAccepted,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
|
||||
|
||||
// A different decision is a new event and receives a new timestamp.
|
||||
rejectedDecisionTime := laterDecisionTime.Add(time.Hour)
|
||||
testClock.SetTime(rejectedDecisionTime)
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, swapHashPending, ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionRejected,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
|
||||
rejectedDecisionTime,
|
||||
))
|
||||
|
||||
// Rejected is terminal: a racing synthetic acceptance must not replace
|
||||
// the server's rejection or move its deadline anchor.
|
||||
testClock.SetTime(rejectedDecisionTime.Add(time.Hour))
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionRejected,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(
|
||||
rejectedDecisionTime,
|
||||
))
|
||||
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, lntypes.Hash{0x9, 0x9, 0x9},
|
||||
ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrLoopInNotFound)
|
||||
|
||||
require.Len(t, swap.Deposits, 2)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type DepositManager interface {
|
||||
// EnsureDepositsFresh reconciles active deposits with the wallet view.
|
||||
EnsureDepositsFresh(ctx context.Context) error
|
||||
|
||||
// AllOutpointsActiveDeposits returns all deposits that are in the
|
||||
// given state. If the state filter is fsm.StateTypeNone, all deposits
|
||||
// are returned.
|
||||
|
|
|
|||
|
|
@ -267,11 +267,6 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
).FeePerKWeight()
|
||||
}
|
||||
|
||||
// There are three ways in which we select deposits to open a channel
|
||||
// with. 1.) The user manually selects the deposits. 2.) The user only
|
||||
// selects a local channel amount in which case we coin-select deposits
|
||||
// to cover for it. 3.) The user selects the fundmax flag, in which case
|
||||
// we select all deposits to fund the channel.
|
||||
if len(req.Outpoints) > 0 {
|
||||
// Ensure that the deposits are in a state in which they are
|
||||
// available for a channel open.
|
||||
|
|
@ -288,6 +283,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, fmt.Errorf("%w in request", err)
|
||||
}
|
||||
|
||||
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
deposits, allActive =
|
||||
m.cfg.DepositManager.AllOutpointsActiveDeposits(
|
||||
outpoints, deposit.Deposited,
|
||||
|
|
@ -296,6 +297,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, ErrOpeningChannelUnavailableDeposits
|
||||
}
|
||||
} else {
|
||||
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to refresh deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
// We have to select the deposits that are used to fund the
|
||||
// channel.
|
||||
deposits, err = m.cfg.DepositManager.GetActiveDepositsInState(
|
||||
|
|
@ -305,6 +312,10 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Automatic channel funding must ignore mempool deposits because
|
||||
// they cannot yet be used as funding inputs.
|
||||
deposits = filterConfirmedDeposits(deposits)
|
||||
|
||||
// If a local funding amount is set, coin-select deposits to
|
||||
// cover it. Otherwise fundmax uses all available deposits.
|
||||
if req.LocalFundingAmount != 0 {
|
||||
|
|
@ -319,6 +330,14 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
// Deposited now includes mempool outputs for static loop-ins, but
|
||||
// channel opens still require the deposit input to be confirmed.
|
||||
if d.GetConfirmationHeight() <= 0 {
|
||||
return nil, ErrOpeningChannelUnavailableDeposits
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-check: calculate the channel funding amount and the optional
|
||||
// change before locking deposits. This ensures the selected deposits
|
||||
// can cover the funding amount plus fees.
|
||||
|
|
@ -394,6 +413,22 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// filterConfirmedDeposits filters the given deposits and returns only those
|
||||
// that have a positive confirmation height, i.e. deposits that have been
|
||||
// confirmed on-chain.
|
||||
func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit {
|
||||
confirmed := make([]*deposit.Deposit, 0, len(deposits))
|
||||
for _, d := range deposits {
|
||||
if d.GetConfirmationHeight() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
confirmed = append(confirmed, d)
|
||||
}
|
||||
|
||||
return confirmed
|
||||
}
|
||||
|
||||
// openChannelPsbt starts an interactive channel open protocol that uses a
|
||||
// partially signed bitcoin transaction (PSBT) to fund the channel output. The
|
||||
// protocol involves several steps between the loop client and the server:
|
||||
|
|
|
|||
|
|
@ -29,12 +29,17 @@ type transitionCall struct {
|
|||
}
|
||||
|
||||
type mockDepositManager struct {
|
||||
activeDeposits []*deposit.Deposit
|
||||
openingDeposits []*deposit.Deposit
|
||||
getErr error
|
||||
transitionErrs map[fsm.EventType]error
|
||||
calls []transitionCall
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
|
||||
fsm.StateType) ([]*deposit.Deposit, bool) {
|
||||
|
||||
|
|
@ -44,15 +49,19 @@ func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
|
|||
func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
if stateFilter != deposit.OpeningChannel {
|
||||
return nil, nil
|
||||
switch stateFilter {
|
||||
case deposit.Deposited:
|
||||
return m.activeDeposits, nil
|
||||
|
||||
case deposit.OpeningChannel:
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
|
||||
return m.openingDeposits, nil
|
||||
}
|
||||
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
|
||||
return m.openingDeposits, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
|
||||
|
|
@ -464,6 +473,97 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) {
|
|||
require.ErrorContains(t, err, "duplicate outpoint")
|
||||
}
|
||||
|
||||
// TestOpenChannelSkipsUnconfirmedAutoSelection verifies that automatic coin
|
||||
// selection ignores mempool deposits and keeps using confirmed ones.
|
||||
func TestOpenChannelSkipsUnconfirmedAutoSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmedA := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(1),
|
||||
Value: 160_000,
|
||||
ConfirmationHeight: 10,
|
||||
}
|
||||
confirmedB := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(2),
|
||||
Value: 140_000,
|
||||
ConfirmationHeight: 11,
|
||||
}
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(3),
|
||||
Value: 500_000,
|
||||
}
|
||||
|
||||
depositManager := &mockDepositManager{
|
||||
activeDeposits: []*deposit.Deposit{
|
||||
unconfirmed, confirmedA, confirmedB,
|
||||
},
|
||||
transitionErrs: map[fsm.EventType]error{
|
||||
deposit.OnOpeningChannel: errors.New("stop after selection"),
|
||||
},
|
||||
}
|
||||
manager := &Manager{
|
||||
cfg: &Config{
|
||||
DepositManager: depositManager,
|
||||
},
|
||||
}
|
||||
|
||||
req := &lnrpc.OpenChannelRequest{
|
||||
NodePubkey: make([]byte, 33),
|
||||
LocalFundingAmount: 100_000,
|
||||
SatPerVbyte: 10,
|
||||
}
|
||||
|
||||
_, err := manager.OpenChannel(context.Background(), req)
|
||||
require.ErrorContains(t, err, "stop after selection")
|
||||
require.Len(t, depositManager.calls, 1)
|
||||
require.Equal(t, deposit.OnOpeningChannel, depositManager.calls[0].event)
|
||||
require.NotContains(t, depositManager.calls[0].outpoints, unconfirmed.OutPoint)
|
||||
}
|
||||
|
||||
// TestOpenChannelFundMaxSkipsUnconfirmed verifies that fundmax only locks
|
||||
// confirmed deposits.
|
||||
func TestOpenChannelFundMaxSkipsUnconfirmed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(1),
|
||||
Value: 200_000,
|
||||
ConfirmationHeight: 10,
|
||||
}
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(2),
|
||||
Value: 300_000,
|
||||
}
|
||||
|
||||
depositManager := &mockDepositManager{
|
||||
activeDeposits: []*deposit.Deposit{
|
||||
unconfirmed, confirmed,
|
||||
},
|
||||
transitionErrs: map[fsm.EventType]error{
|
||||
deposit.OnOpeningChannel: errors.New("stop after selection"),
|
||||
},
|
||||
}
|
||||
manager := &Manager{
|
||||
cfg: &Config{
|
||||
DepositManager: depositManager,
|
||||
},
|
||||
}
|
||||
|
||||
req := &lnrpc.OpenChannelRequest{
|
||||
NodePubkey: make([]byte, 33),
|
||||
FundMax: true,
|
||||
SatPerVbyte: 10,
|
||||
}
|
||||
|
||||
_, err := manager.OpenChannel(context.Background(), req)
|
||||
require.ErrorContains(t, err, "stop after selection")
|
||||
require.Len(t, depositManager.calls, 1)
|
||||
require.Equal(
|
||||
t, []wire.OutPoint{confirmed.OutPoint},
|
||||
depositManager.calls[0].outpoints,
|
||||
)
|
||||
}
|
||||
|
||||
// TestValidateInitialPsbtFlags verifies that request fields incompatible with
|
||||
// PSBT funding are rejected early, before any deposits are locked.
|
||||
func TestValidateInitialPsbtFlags(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -21,14 +21,24 @@ type AddressManager interface {
|
|||
}
|
||||
|
||||
type DepositManager interface {
|
||||
// EnsureDepositsFresh reconciles active deposits with the wallet view.
|
||||
EnsureDepositsFresh(ctx context.Context) error
|
||||
|
||||
// GetActiveDepositsInState returns all active deposits in the given
|
||||
// state.
|
||||
GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit,
|
||||
error)
|
||||
|
||||
// AllOutpointsActiveDeposits returns all active deposits referenced by
|
||||
// the outpoints if every deposit is active and in the given state.
|
||||
AllOutpointsActiveDeposits(outpoints []wire.OutPoint,
|
||||
stateFilter fsm.StateType) ([]*deposit.Deposit, bool)
|
||||
|
||||
// TransitionDeposits transitions the deposits with the given event and
|
||||
// waits until they reach the expected final state.
|
||||
TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit,
|
||||
event fsm.EventType, expectedFinalState fsm.StateType) error
|
||||
|
||||
// UpdateDeposit persists the current deposit fields.
|
||||
UpdateDeposit(ctx context.Context, d *deposit.Deposit) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,11 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
allWithdrawing bool
|
||||
)
|
||||
|
||||
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to refresh deposits: %w", err)
|
||||
}
|
||||
|
||||
// Ensure that the deposits are in a state in which they can be
|
||||
// withdrawn.
|
||||
deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits(
|
||||
|
|
@ -381,10 +386,16 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
var (
|
||||
withdrawalAddress btcutil.Address
|
||||
err error
|
||||
)
|
||||
for _, d := range deposits {
|
||||
// Deposited now includes mempool outputs for static loop-ins, but
|
||||
// withdrawals still require the deposit input to be confirmed.
|
||||
if d.GetConfirmationHeight() <= 0 {
|
||||
return "", "", fmt.Errorf("can't withdraw, " +
|
||||
"unconfirmed deposits can't be withdrawn")
|
||||
}
|
||||
}
|
||||
|
||||
var withdrawalAddress btcutil.Address
|
||||
|
||||
// Check if the user provided an address to withdraw to. If not, we'll
|
||||
// generate a new address for them.
|
||||
|
|
|
|||
|
|
@ -155,7 +155,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
|
|||
return nil, fmt.Errorf("invoice: %x not found", hash)
|
||||
}
|
||||
|
||||
return inv, nil
|
||||
invoiceCopy := *inv
|
||||
|
||||
return &invoiceCopy, nil
|
||||
}
|
||||
|
||||
// ListTransactions returns all known transactions of the backing lnd node.
|
||||
|
|
|
|||
|
|
@ -225,6 +225,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
|
|||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
// SetInvoice stores a copy of the given invoice in the mock invoice store.
|
||||
func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
invoiceCopy := *invoice
|
||||
s.Invoices[invoice.Hash] = &invoiceCopy
|
||||
}
|
||||
|
||||
// IsDone checks whether all channels have been fully emptied. If not this may
|
||||
// indicate unexpected behaviour of the code under test.
|
||||
func (s *LndMockServices) IsDone() error {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue