mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Compare commits
15 commits
master
...
dyn-conf-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d424169011 | ||
|
|
8ae163c15d | ||
|
|
b3fd80e31a | ||
|
|
325545143c | ||
|
|
7b29d9215e | ||
|
|
8417c57be3 | ||
|
|
9818ad8985 | ||
|
|
7ef65c9fde | ||
|
|
858066dd0c | ||
|
|
6d70f0fe47 | ||
|
|
5a7a953810 | ||
|
|
cbe8eecb36 | ||
|
|
30a14ecc82 | ||
|
|
3b29ef3629 | ||
|
|
3df71f9a19 |
44 changed files with 5712 additions and 378 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"
|
||||
lndcommands "github.com/lightningnetwork/lnd/cmd/commands"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
|
@ -553,11 +556,7 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
allDeposits := depositList.FilteredDeposits
|
||||
|
||||
if len(allDeposits) == 0 {
|
||||
errString := fmt.Sprintf("no confirmed deposits available, "+
|
||||
"deposits need at least %v confirmations",
|
||||
deposit.MinConfs)
|
||||
|
||||
return errors.New(errString)
|
||||
return errors.New("no deposited outputs available")
|
||||
}
|
||||
|
||||
var depositOutpoints []string
|
||||
|
|
@ -614,6 +613,28 @@ func staticAddressLoopIn(ctx context.Context, cmd *cli.Command) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Warn the user if any selected deposits have fewer than 6
|
||||
// confirmations, as the swap payment won't be received immediately
|
||||
// for those.
|
||||
summary, err := client.GetStaticAddressSummary(
|
||||
ctx, &looprpc.StaticAddressSummaryRequest{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
depositsToCheck := warningDepositOutpoints(
|
||||
allDeposits, depositOutpoints, autoSelectDepositsForQuote,
|
||||
quoteReq.Amt,
|
||||
)
|
||||
warning := lowConfDepositWarning(
|
||||
allDeposits, depositsToCheck,
|
||||
int64(summary.RelativeExpiryBlocks),
|
||||
)
|
||||
if warning != "" {
|
||||
fmt.Println(warning)
|
||||
}
|
||||
|
||||
if !(cmd.Bool("force") || cmd.Bool("f")) {
|
||||
err = displayInDetails(quoteReq, quote, cmd.Bool("verbose"))
|
||||
if err != nil {
|
||||
|
|
@ -669,6 +690,162 @@ func depositsToOutpoints(deposits []*looprpc.Deposit) []string {
|
|||
return outpoints
|
||||
}
|
||||
|
||||
var warningSelectionDustLimit = int64(lnwallet.DustLimitForSize(input.P2TRSize))
|
||||
|
||||
// warningDepositOutpoints returns the deposit outpoints to check for
|
||||
// low-confirmation warnings.
|
||||
func warningDepositOutpoints(allDeposits []*looprpc.Deposit,
|
||||
selectedOutpoints []string, autoSelect bool, targetAmount int64) []string {
|
||||
|
||||
if !autoSelect {
|
||||
return selectedOutpoints
|
||||
}
|
||||
|
||||
return autoSelectedWarningOutpoints(allDeposits, targetAmount)
|
||||
}
|
||||
|
||||
// autoSelectedWarningOutpoints returns the outpoints selected by the same
|
||||
// ordering used for automatic static loop-in deposit selection.
|
||||
func autoSelectedWarningOutpoints(allDeposits []*looprpc.Deposit,
|
||||
targetAmount int64) []string {
|
||||
|
||||
if targetAmount <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with staticaddr/loopin.SelectDeposits.
|
||||
deposits := filterSwappableWarningDeposits(allDeposits)
|
||||
sort.Slice(deposits, func(i, j int) bool {
|
||||
iConfirmed := deposits[i].ConfirmationHeight > 0
|
||||
jConfirmed := deposits[j].ConfirmationHeight > 0
|
||||
if iConfirmed != jConfirmed {
|
||||
return iConfirmed
|
||||
}
|
||||
|
||||
if deposits[i].Value == deposits[j].Value {
|
||||
return deposits[i].BlocksUntilExpiry <
|
||||
deposits[j].BlocksUntilExpiry
|
||||
}
|
||||
|
||||
return deposits[i].Value > deposits[j].Value
|
||||
})
|
||||
|
||||
selectedOutpoints := make([]string, 0, len(deposits))
|
||||
var selectedAmount int64
|
||||
for _, deposit := range deposits {
|
||||
selectedOutpoints = append(selectedOutpoints, deposit.Outpoint)
|
||||
selectedAmount += deposit.Value
|
||||
if selectedAmount == targetAmount {
|
||||
return selectedOutpoints
|
||||
}
|
||||
|
||||
if selectedAmount > targetAmount &&
|
||||
selectedAmount-targetAmount >= warningSelectionDustLimit {
|
||||
|
||||
return selectedOutpoints
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterSwappableWarningDeposits filters deposits for CLI warning selection.
|
||||
func filterSwappableWarningDeposits(
|
||||
allDeposits []*looprpc.Deposit) []*looprpc.Deposit {
|
||||
|
||||
swappable := make([]*looprpc.Deposit, 0, len(allDeposits))
|
||||
minBlocksUntilExpiry := int64(
|
||||
loopin.DefaultLoopInOnChainCltvDelta + loopin.DepositHtlcDelta,
|
||||
)
|
||||
for _, deposit := range allDeposits {
|
||||
// Unconfirmed deposits remain swappable because their CSV timeout has
|
||||
// not started yet. This mirrors loopin.IsSwappable.
|
||||
if deposit.ConfirmationHeight > 0 &&
|
||||
deposit.BlocksUntilExpiry < minBlocksUntilExpiry {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
swappable = append(swappable, deposit)
|
||||
}
|
||||
|
||||
return swappable
|
||||
}
|
||||
|
||||
// conservativeWarningConfs is the highest default confirmation tier used by
|
||||
// the server's dynamic confirmation-risk policy.
|
||||
//
|
||||
// The CLI does not currently know the server's exact policy, so we use this
|
||||
// conservative threshold for warnings without promising immediate execution.
|
||||
const conservativeWarningConfs = 6
|
||||
|
||||
// lowConfDepositWarning checks the selected deposits against a conservative
|
||||
// confirmation threshold and returns a warning string if any are found.
|
||||
func lowConfDepositWarning(allDeposits []*looprpc.Deposit,
|
||||
selectedOutpoints []string, csvExpiry int64) string {
|
||||
|
||||
depositMap := make(map[string]*looprpc.Deposit, len(allDeposits))
|
||||
for _, d := range allDeposits {
|
||||
depositMap[d.Outpoint] = d
|
||||
}
|
||||
|
||||
var lowConfEntries []string
|
||||
for _, op := range selectedOutpoints {
|
||||
d, ok := depositMap[op]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var confs int64
|
||||
switch {
|
||||
case d.ConfirmationHeight <= 0:
|
||||
confs = 0
|
||||
|
||||
case csvExpiry > 0:
|
||||
// For confirmed deposits we can compute
|
||||
// confirmations as CSVExpiry - BlocksUntilExpiry + 1.
|
||||
confs = csvExpiry - d.BlocksUntilExpiry + 1
|
||||
|
||||
default:
|
||||
// Can't determine confirmations without the CSV expiry.
|
||||
continue
|
||||
}
|
||||
|
||||
if confs >= conservativeWarningConfs {
|
||||
continue
|
||||
}
|
||||
|
||||
if confs == 0 {
|
||||
lowConfEntries = append(
|
||||
lowConfEntries,
|
||||
fmt.Sprintf(" - %s (unconfirmed)", op),
|
||||
)
|
||||
} else {
|
||||
lowConfEntries = append(
|
||||
lowConfEntries,
|
||||
fmt.Sprintf(
|
||||
" - %s (%d confirmations)", op,
|
||||
confs,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(lowConfEntries) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"\nWARNING: The following deposits are below the "+
|
||||
"conservative %d-confirmation threshold:\n%s\n"+
|
||||
"The swap payment for these deposits may wait for "+
|
||||
"more confirmations depending on the server's "+
|
||||
"confirmation-risk policy.\n",
|
||||
conservativeWarningConfs,
|
||||
strings.Join(lowConfEntries, "\n"),
|
||||
)
|
||||
}
|
||||
|
||||
func displayNewAddressWarning() error {
|
||||
fmt.Printf("\nWARNING: Be aware that loosing your l402.token file in " +
|
||||
".loop under your home directory will take your ability to " +
|
||||
|
|
|
|||
220
cmd/loop/staticaddr_test.go
Normal file
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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -622,6 +642,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
|
|||
depositStore := deposit.NewSqlStore(baseDb)
|
||||
depoCfg := &deposit.ManagerConfig{
|
||||
AddressManager: staticAddressManager,
|
||||
ChainKit: d.lnd.ChainKit,
|
||||
Store: depositStore,
|
||||
WalletKit: d.lnd.WalletKit,
|
||||
ChainNotifier: d.lnd.ChainNotifier,
|
||||
|
|
@ -662,12 +683,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,
|
||||
|
|
@ -699,6 +714,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
|
|||
Store: staticAddressLoopInStore,
|
||||
WalletKit: d.lnd.WalletKit,
|
||||
ChainNotifier: d.lnd.ChainNotifier,
|
||||
TxOutChecker: loopin.NewLndTxOutChecker(d.lnd.Client),
|
||||
NotificationManager: notificationManager,
|
||||
ChainParams: d.lnd.ChainParams,
|
||||
Signer: d.lnd.Signer,
|
||||
|
|
|
|||
|
|
@ -976,15 +976,24 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
|
|||
return nil, fmt.Errorf("expected %d deposits, got %d",
|
||||
len(req.DepositOutpoints),
|
||||
len(depositList.FilteredDeposits))
|
||||
} else {
|
||||
numDeposits = len(depositList.FilteredDeposits)
|
||||
}
|
||||
numDeposits = len(depositList.FilteredDeposits)
|
||||
|
||||
// In case we quote for deposits, we send the server both the
|
||||
// selected value and the number of deposits. This is so the
|
||||
// server can probe the selected value and calculate the per
|
||||
// input fee.
|
||||
for _, deposit := range depositList.FilteredDeposits {
|
||||
// ListStaticAddressDeposits only filters out deposits that are no
|
||||
// longer visible to the user, such as Replaced records. For a manual
|
||||
// quote we additionally require the current state to be Deposited so a
|
||||
// stale client-side outpoint selection fails early instead of making it
|
||||
// to swap initiation.
|
||||
if deposit.State != looprpc.DepositState_DEPOSITED {
|
||||
return nil, fmt.Errorf("deposit %s is not "+
|
||||
"currently available", deposit.Outpoint)
|
||||
}
|
||||
|
||||
totalDepositAmount += btcutil.Amount(
|
||||
deposit.Value,
|
||||
)
|
||||
|
|
@ -1692,58 +1701,35 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
|
|||
}
|
||||
|
||||
// ListUnspentRaw returns the unspent wallet view of the backing lnd
|
||||
// wallet. It might be that deposits show up there that are actually
|
||||
// not spendable because they already have been used but not yet spent
|
||||
// by the server. We filter out such deposits here.
|
||||
// wallet. Static loop-in initiation requires an active deposit record,
|
||||
// so only deposits that are both wallet-visible and tracked as
|
||||
// Deposited are returned here.
|
||||
var (
|
||||
outpoints []string
|
||||
isUnspent = make(map[wire.OutPoint]struct{})
|
||||
)
|
||||
|
||||
// Keep track of confirmed outpoints that we need to check against our
|
||||
// database.
|
||||
confirmedToCheck := make(map[wire.OutPoint]struct{})
|
||||
|
||||
for _, utxo := range utxos {
|
||||
if utxo.Confirmations < deposit.MinConfs {
|
||||
// Unconfirmed deposits are always available.
|
||||
isUnspent[utxo.OutPoint] = struct{}{}
|
||||
} else {
|
||||
// Confirmed deposits need to be checked.
|
||||
outpoints = append(outpoints, utxo.OutPoint.String())
|
||||
confirmedToCheck[utxo.OutPoint] = struct{}{}
|
||||
}
|
||||
outpoints = append(outpoints, utxo.OutPoint.String())
|
||||
}
|
||||
|
||||
// Check the spent status of the deposits by looking at their states.
|
||||
ignoreUnknownOutpoints := false
|
||||
ignoreUnknownOutpoints := true
|
||||
deposits, err := s.depositManager.DepositsForOutpoints(
|
||||
ctx, outpoints, ignoreUnknownOutpoints,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
// A nil deposit means we don't have a record for it. We'll
|
||||
// handle this case after the loop.
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the deposit is in the "Deposited" state, it's available.
|
||||
if d.IsInState(deposit.Deposited) {
|
||||
isUnspent[d.OutPoint] = struct{}{}
|
||||
}
|
||||
|
||||
// We have a record for this deposit, so we no longer need to
|
||||
// check it.
|
||||
delete(confirmedToCheck, d.OutPoint)
|
||||
}
|
||||
|
||||
// Any remaining outpoints in confirmedToCheck are ones that lnd knows
|
||||
// about but we don't. These are new, unspent deposits.
|
||||
for op := range confirmedToCheck {
|
||||
isUnspent[op] = struct{}{}
|
||||
}
|
||||
|
||||
// Prepare the list of unspent deposits for the rpc response.
|
||||
|
|
@ -1790,8 +1776,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
outpoints = append(outpoints, d.OutPoint)
|
||||
outpoints, err = withdrawAllDepositOutpoints(deposits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
case isUtxoSelected:
|
||||
|
|
@ -1814,6 +1801,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
|
|||
}, err
|
||||
}
|
||||
|
||||
// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
|
||||
// withdrawal request. The request must fail if any deposited output is still
|
||||
// unconfirmed because `all` should not silently downgrade to a subset.
|
||||
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
|
||||
error) {
|
||||
|
||||
outpoints := make([]wire.OutPoint, 0, len(deposits))
|
||||
for _, d := range deposits {
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
return nil, fmt.Errorf("can't withdraw all deposits while " +
|
||||
"some deposits are unconfirmed")
|
||||
}
|
||||
|
||||
outpoints = append(outpoints, d.OutPoint)
|
||||
}
|
||||
|
||||
return outpoints, nil
|
||||
}
|
||||
|
||||
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
|
||||
// deposits behind the static address and displays properties like value,
|
||||
// state or blocks til expiry.
|
||||
|
|
@ -1838,7 +1844,8 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
|
|||
var filteredDeposits []*looprpc.Deposit
|
||||
if len(outpoints) > 0 {
|
||||
f := func(d *deposit.Deposit) bool {
|
||||
return slices.Contains(outpoints, d.OutPoint.String())
|
||||
return isVisibleDeposit(d) &&
|
||||
slices.Contains(outpoints, d.OutPoint.String())
|
||||
}
|
||||
filteredDeposits = filter(allDeposits, f)
|
||||
|
||||
|
|
@ -1848,6 +1855,10 @@ func (s *swapClientServer) ListStaticAddressDeposits(ctx context.Context,
|
|||
}
|
||||
} else {
|
||||
f := func(d *deposit.Deposit) bool {
|
||||
if !isVisibleDeposit(d) {
|
||||
return false
|
||||
}
|
||||
|
||||
if req.StateFilter == looprpc.DepositState_UNKNOWN_STATE {
|
||||
// Per default, we return deposits in all
|
||||
// states.
|
||||
|
|
@ -1982,9 +1993,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
|
|||
protoDeposits = make([]*looprpc.Deposit, 0, len(ds))
|
||||
for _, d := range ds {
|
||||
state := toClientDepositState(d.GetState())
|
||||
blocksUntilExpiry := d.ConfirmationHeight +
|
||||
int64(addrParams.Expiry) -
|
||||
int64(lndInfo.BlockHeight)
|
||||
blocksUntilExpiry := depositBlocksUntilExpiry(
|
||||
d.ConfirmationHeight, addrParams.Expiry,
|
||||
int64(lndInfo.BlockHeight),
|
||||
)
|
||||
|
||||
pd := &looprpc.Deposit{
|
||||
Id: d.ID[:],
|
||||
|
|
@ -2033,6 +2045,7 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allDeposits = filterDeposits(allDeposits, isVisibleDeposit)
|
||||
|
||||
var (
|
||||
totalNumDeposits = len(allDeposits)
|
||||
|
|
@ -2045,23 +2058,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
|
|||
htlcTimeoutSwept int64
|
||||
)
|
||||
|
||||
// Value unconfirmed.
|
||||
utxos, err := s.staticAddressManager.ListUnspent(
|
||||
ctx, 0, deposit.MinConfs-1,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range utxos {
|
||||
valueUnconfirmed += int64(u.Value)
|
||||
}
|
||||
|
||||
// Confirmed total values by category.
|
||||
// Total values by category.
|
||||
for _, d := range allDeposits {
|
||||
value := int64(d.Value)
|
||||
switch d.GetState() {
|
||||
case deposit.Deposited:
|
||||
valueDeposited += value
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
valueUnconfirmed += value
|
||||
} else {
|
||||
valueDeposited += value
|
||||
}
|
||||
|
||||
case deposit.Expired:
|
||||
valueExpired += value
|
||||
|
|
@ -2211,13 +2217,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
|
|||
return err
|
||||
}
|
||||
for i := range len(deposits) {
|
||||
deposits[i].BlocksUntilExpiry =
|
||||
deposits[i].ConfirmationHeight +
|
||||
int64(params.Expiry) - bestBlockHeight
|
||||
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
|
||||
deposits[i].ConfirmationHeight, params.Expiry,
|
||||
bestBlockHeight,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// depositBlocksUntilExpiry returns the remaining blocks until a deposit
|
||||
// expires. Unconfirmed deposits return the full CSV value because the timeout
|
||||
// has not started yet.
|
||||
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
|
||||
bestBlockHeight int64) int64 {
|
||||
|
||||
if confirmationHeight <= 0 {
|
||||
return int64(expiry)
|
||||
}
|
||||
|
||||
return confirmationHeight + int64(expiry) - bestBlockHeight
|
||||
}
|
||||
|
||||
// StaticOpenChannel initiates an open channel request using static address
|
||||
// deposits.
|
||||
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
|
||||
|
|
@ -2247,6 +2267,30 @@ func (s *swapClientServer) StaticOpenChannel(ctx context.Context,
|
|||
|
||||
type filterFunc func(deposits *deposit.Deposit) bool
|
||||
|
||||
// filterDeposits returns all deposits accepted by the given predicate.
|
||||
func filterDeposits(deposits []*deposit.Deposit,
|
||||
f filterFunc) []*deposit.Deposit {
|
||||
|
||||
filtered := make([]*deposit.Deposit, 0, len(deposits))
|
||||
for _, deposit := range deposits {
|
||||
if !f(deposit) {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, deposit)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// isVisibleDeposit returns true if a deposit should appear in normal listings.
|
||||
func isVisibleDeposit(d *deposit.Deposit) bool {
|
||||
// Replaced deposits are kept in the DB as history, but they should disappear
|
||||
// from normal deposit listings and summary totals because the underlying
|
||||
// outpoint is no longer present in the wallet and cannot be spent.
|
||||
return d.GetState() != deposit.Replaced
|
||||
}
|
||||
|
||||
func filter(deposits []*deposit.Deposit, f filterFunc) []*looprpc.Deposit {
|
||||
var clientDeposits []*looprpc.Deposit
|
||||
for _, d := range deposits {
|
||||
|
|
|
|||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
225
loopd/swapclient_server_staticaddr_test.go
Normal file
225
loopd/swapclient_server_staticaddr_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package loopd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btclog/v2"
|
||||
"github.com/lightninglabs/loop/looprpc"
|
||||
"github.com/lightninglabs/loop/staticaddr/address"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
"github.com/lightninglabs/loop/staticaddr/script"
|
||||
mock_lnd "github.com/lightninglabs/loop/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type staticAddrDepositStore struct {
|
||||
allDeposits []*deposit.Deposit
|
||||
byOutpoint map[string]*deposit.Deposit
|
||||
}
|
||||
|
||||
// CreateDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) CreateDeposit(context.Context,
|
||||
*deposit.Deposit) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) UpdateDeposit(context.Context,
|
||||
*deposit.Deposit) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeposit implements deposit.Store for static address server tests.
|
||||
func (s *staticAddrDepositStore) GetDeposit(context.Context,
|
||||
deposit.ID) (*deposit.Deposit, error) {
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// DepositForOutpoint returns the deposit for the requested outpoint.
|
||||
func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context,
|
||||
outpoint string) (*deposit.Deposit, error) {
|
||||
|
||||
if deposit, ok := s.byOutpoint[outpoint]; ok {
|
||||
return deposit, nil
|
||||
}
|
||||
|
||||
return nil, deposit.ErrDepositNotFound
|
||||
}
|
||||
|
||||
// AllDeposits returns all deposits seeded into the test store.
|
||||
func (s *staticAddrDepositStore) AllDeposits(context.Context) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
return s.allDeposits, nil
|
||||
}
|
||||
|
||||
// newTestDepositManager creates a deposit manager backed by seeded deposits.
|
||||
func newTestDepositManager(
|
||||
deposits ...*deposit.Deposit) *deposit.Manager {
|
||||
|
||||
byOutpoint := make(map[string]*deposit.Deposit, len(deposits))
|
||||
for _, deposit := range deposits {
|
||||
byOutpoint[deposit.OutPoint.String()] = deposit
|
||||
}
|
||||
|
||||
return deposit.NewManager(&deposit.ManagerConfig{
|
||||
Store: &staticAddrDepositStore{
|
||||
allDeposits: deposits,
|
||||
byOutpoint: byOutpoint,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// newTestStaticAddressContext creates static address test dependencies.
|
||||
func newTestStaticAddressContext(t *testing.T) (*address.Manager,
|
||||
*mock_lnd.LndMockServices) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
mock := mock_lnd.NewMockLnd()
|
||||
_, client := mock_lnd.CreateKey(1)
|
||||
_, server := mock_lnd.CreateKey(2)
|
||||
|
||||
addrStore := &mockAddressStore{
|
||||
params: []*script.Parameters{{
|
||||
ClientPubkey: client,
|
||||
ServerPubkey: server,
|
||||
Expiry: 10,
|
||||
PkScript: []byte("pkscript"),
|
||||
}},
|
||||
}
|
||||
|
||||
addrMgr, err := address.NewManager(&address.ManagerConfig{
|
||||
Store: addrStore,
|
||||
WalletKit: mock.WalletKit,
|
||||
ChainParams: mock.ChainParams,
|
||||
}, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
return addrMgr, mock
|
||||
}
|
||||
|
||||
// TestListStaticAddressDepositsHidesReplaced verifies replaced deposits are
|
||||
// hidden from normal deposit listings.
|
||||
func TestListStaticAddressDepositsHidesReplaced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
replaced := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{1},
|
||||
Index: 1,
|
||||
},
|
||||
}
|
||||
replaced.SetState(deposit.Replaced)
|
||||
|
||||
available := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{2},
|
||||
Index: 2,
|
||||
},
|
||||
}
|
||||
available.SetState(deposit.Deposited)
|
||||
|
||||
addrMgr, lnd := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(replaced, available),
|
||||
staticAddressManager: addrMgr,
|
||||
lnd: &lnd.LndServices,
|
||||
}
|
||||
|
||||
resp, err := server.ListStaticAddressDeposits(
|
||||
context.Background(), &looprpc.ListStaticAddressDepositsRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resp.FilteredDeposits, 1)
|
||||
require.Equal(
|
||||
t, available.OutPoint.String(),
|
||||
resp.FilteredDeposits[0].Outpoint,
|
||||
)
|
||||
}
|
||||
|
||||
// TestGetStaticAddressSummaryIgnoresReplaced verifies replaced deposits are
|
||||
// excluded from static address summary totals.
|
||||
func TestGetStaticAddressSummaryIgnoresReplaced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
replaced := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{3},
|
||||
Index: 3,
|
||||
},
|
||||
Value: btcutil.Amount(1_000),
|
||||
}
|
||||
replaced.SetState(deposit.Replaced)
|
||||
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{4},
|
||||
Index: 4,
|
||||
},
|
||||
Value: btcutil.Amount(2_000),
|
||||
ConfirmationHeight: 0,
|
||||
}
|
||||
unconfirmed.SetState(deposit.Deposited)
|
||||
|
||||
confirmed := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{5},
|
||||
Index: 5,
|
||||
},
|
||||
Value: btcutil.Amount(3_000),
|
||||
ConfirmationHeight: 123,
|
||||
}
|
||||
confirmed.SetState(deposit.Deposited)
|
||||
|
||||
addrMgr, _ := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(
|
||||
replaced, unconfirmed, confirmed,
|
||||
),
|
||||
staticAddressManager: addrMgr,
|
||||
}
|
||||
|
||||
resp, err := server.GetStaticAddressSummary(
|
||||
context.Background(), &looprpc.StaticAddressSummaryRequest{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, resp.TotalNumDeposits)
|
||||
require.EqualValues(t, 2_000, resp.ValueUnconfirmedSatoshis)
|
||||
require.EqualValues(t, 3_000, resp.ValueDepositedSatoshis)
|
||||
}
|
||||
|
||||
// TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote
|
||||
// requests fail for selected deposits that are no longer available.
|
||||
func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) {
|
||||
t.Parallel()
|
||||
setLogger(btclog.Disabled)
|
||||
|
||||
locked := &deposit.Deposit{
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{6},
|
||||
Index: 6,
|
||||
},
|
||||
Value: btcutil.Amount(5_000),
|
||||
}
|
||||
locked.SetState(deposit.LoopingIn)
|
||||
|
||||
addrMgr, lnd := newTestStaticAddressContext(t)
|
||||
server := &swapClientServer{
|
||||
depositManager: newTestDepositManager(locked),
|
||||
staticAddressManager: addrMgr,
|
||||
lnd: &lnd.LndServices,
|
||||
}
|
||||
|
||||
_, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{
|
||||
DepositOutpoints: []string{locked.OutPoint.String()},
|
||||
})
|
||||
require.ErrorContains(t, err, "is not currently available")
|
||||
}
|
||||
|
|
@ -1071,7 +1071,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
|
|||
if d, ok := s.byOutpoint[outpoint]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, nil
|
||||
return nil, deposit.ErrDepositNotFound
|
||||
}
|
||||
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
|
||||
error) {
|
||||
|
|
@ -1120,11 +1120,11 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
minConfs := int64(deposit.MinConfs)
|
||||
utxoBelow := makeUtxo(0, minConfs-1) // always included
|
||||
utxoAt := makeUtxo(1, minConfs) // included only if Deposited
|
||||
utxoAbove1 := makeUtxo(2, minConfs+1)
|
||||
utxoAbove2 := makeUtxo(3, minConfs+2)
|
||||
utxoUnknown := makeUtxo(0, 0)
|
||||
utxoDeposited := makeUtxo(1, 1)
|
||||
utxoWithdrawn := makeUtxo(2, 2)
|
||||
utxoLoopingIn := makeUtxo(3, 5)
|
||||
utxoConfirmedUnknown := makeUtxo(4, 3)
|
||||
|
||||
// Helper to build the deposit manager with specific states.
|
||||
buildDepositMgr := func(
|
||||
|
|
@ -1142,17 +1142,19 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
|
||||
}
|
||||
|
||||
// Include below-min-conf and >=min with Deposited; exclude others.
|
||||
t.Run("below min conf always, Deposited included, others excluded",
|
||||
// Only known Deposited records are available. Unknown deposits and
|
||||
// known non-Deposited states are excluded.
|
||||
t.Run("only known Deposited included",
|
||||
func(t *testing.T) {
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{
|
||||
utxoBelow, utxoAt, utxoAbove1, utxoAbove2,
|
||||
utxoUnknown, utxoDeposited, utxoWithdrawn,
|
||||
utxoLoopingIn,
|
||||
})
|
||||
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
||||
utxoAt.OutPoint: deposit.Deposited,
|
||||
utxoAbove1.OutPoint: deposit.Withdrawn,
|
||||
utxoAbove2.OutPoint: deposit.LoopingIn,
|
||||
utxoDeposited.OutPoint: deposit.Deposited,
|
||||
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
||||
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
||||
})
|
||||
|
||||
server := &swapClientServer{
|
||||
|
|
@ -1165,8 +1167,8 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Expect utxoBelow and utxoAt only.
|
||||
require.Len(t, resp.Utxos, 2)
|
||||
// Expect the Deposited utxo only.
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
got := map[string]struct{}{}
|
||||
for _, u := range resp.Utxos {
|
||||
got[u.Outpoint] = struct{}{}
|
||||
|
|
@ -1174,25 +1176,23 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
// same across utxos.
|
||||
require.NotEmpty(t, u.StaticAddress)
|
||||
}
|
||||
_, ok1 := got[utxoBelow.OutPoint.String()]
|
||||
_, ok2 := got[utxoAt.OutPoint.String()]
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
_, ok := got[utxoDeposited.OutPoint.String()]
|
||||
require.True(t, ok)
|
||||
})
|
||||
|
||||
// Swap states, now include utxoBelow and utxoAbove1.
|
||||
t.Run("Deposited on >=min included; non-Deposited excluded",
|
||||
// Confirmation depth no longer changes availability; state does.
|
||||
t.Run("availability ignores conf depth once deposit state is known",
|
||||
func(t *testing.T) {
|
||||
mock.SetListUnspent(
|
||||
[]*lnwallet.Utxo{
|
||||
utxoBelow, utxoAt, utxoAbove1,
|
||||
utxoAbove2,
|
||||
utxoUnknown, utxoDeposited,
|
||||
utxoWithdrawn, utxoLoopingIn,
|
||||
})
|
||||
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
||||
utxoAt.OutPoint: deposit.Withdrawn,
|
||||
utxoAbove1.OutPoint: deposit.Deposited,
|
||||
utxoAbove2.OutPoint: deposit.Withdrawn,
|
||||
utxoDeposited.OutPoint: deposit.Deposited,
|
||||
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
||||
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
||||
})
|
||||
|
||||
server := &swapClientServer{
|
||||
|
|
@ -1205,22 +1205,20 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, resp.Utxos, 2)
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
got := map[string]struct{}{}
|
||||
for _, u := range resp.Utxos {
|
||||
got[u.Outpoint] = struct{}{}
|
||||
}
|
||||
_, ok1 := got[utxoBelow.OutPoint.String()]
|
||||
_, ok2 := got[utxoAbove1.OutPoint.String()]
|
||||
require.True(t, ok1)
|
||||
require.True(t, ok2)
|
||||
_, ok := got[utxoDeposited.OutPoint.String()]
|
||||
require.True(t, ok)
|
||||
})
|
||||
|
||||
// Confirmed UTXO not present in store should be included.
|
||||
t.Run("confirmed utxo not in store is included", func(t *testing.T) {
|
||||
// Confirmed UTXO not present in store should be excluded.
|
||||
t.Run("confirmed utxo not in store is excluded", func(t *testing.T) {
|
||||
// Only return a confirmed UTXO from lnd and make sure the
|
||||
// deposit manager/store doesn't know about it.
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2})
|
||||
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
|
||||
|
||||
// Empty store (no states for any outpoint).
|
||||
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
|
||||
|
|
@ -1235,12 +1233,6 @@ func TestListUnspentDeposits(t *testing.T) {
|
|||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We expect the confirmed UTXO to be included even though it
|
||||
// doesn't exist in the store yet.
|
||||
require.Len(t, resp.Utxos, 1)
|
||||
require.Equal(
|
||||
t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint,
|
||||
)
|
||||
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
|
||||
require.Empty(t, resp.Utxos)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS deposits (
|
|||
amount BIGINT NOT NULL,
|
||||
|
||||
-- confirmation_height is the absolute height at which the deposit was
|
||||
-- confirmed.
|
||||
-- confirmed. A value of 0 means the deposit is still unconfirmed.
|
||||
confirmation_height BIGINT NOT NULL,
|
||||
|
||||
-- timeout_sweep_pk_script is the public key script that will be used to
|
||||
|
|
@ -45,4 +45,4 @@ CREATE TABLE IF NOT EXISTS deposit_updates (
|
|||
|
||||
-- update_timestamp is the timestamp of the update.
|
||||
update_timestamp TIMESTAMP NOT NULL
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,9 @@
|
|||
-- confirmation_risk_decision records the server's confirmation-risk decision
|
||||
-- for a static address loop-in. The empty string means no decision has been
|
||||
-- received yet.
|
||||
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- confirmation_risk_decision_time records when loopd received and persisted
|
||||
-- the server's decision, so payment deadlines can be reconstructed after
|
||||
-- restart.
|
||||
ALTER TABLE static_address_swaps ADD COLUMN confirmation_risk_decision_time TIMESTAMP;
|
||||
|
|
@ -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,14 @@ SET
|
|||
WHERE
|
||||
swap_hash = $1;
|
||||
|
||||
-- name: RecordStaticAddressRiskDecision :exec
|
||||
UPDATE static_address_swaps
|
||||
SET
|
||||
confirmation_risk_decision = $2,
|
||||
confirmation_risk_decision_time = $3
|
||||
WHERE
|
||||
swap_hash = $1;
|
||||
|
||||
-- name: InsertStaticAddressMetaUpdate :exec
|
||||
INSERT INTO static_address_swap_updates (
|
||||
swap_hash,
|
||||
|
|
@ -150,4 +158,3 @@ WHERE
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ func (q *Queries) GetLoopInSwapUpdates(ctx context.Context, swapHash []byte) ([]
|
|||
const getStaticAddressLoopInSwap = `-- name: GetStaticAddressLoopInSwap :one
|
||||
SELECT
|
||||
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
|
||||
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
|
||||
FROM
|
||||
swaps
|
||||
|
|
@ -166,36 +166,38 @@ WHERE
|
|||
`
|
||||
|
||||
type GetStaticAddressLoopInSwapRow struct {
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
}
|
||||
|
||||
func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byte) (GetStaticAddressLoopInSwapRow, error) {
|
||||
|
|
@ -225,6 +227,8 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
|
|||
&i.HtlcTimeoutSweepAddress,
|
||||
&i.SelectedAmount,
|
||||
&i.Fast,
|
||||
&i.ConfirmationRiskDecision,
|
||||
&i.ConfirmationRiskDecisionTime,
|
||||
&i.SwapHash_3,
|
||||
&i.SenderScriptPubkey,
|
||||
&i.ReceiverScriptPubkey,
|
||||
|
|
@ -239,7 +243,7 @@ func (q *Queries) GetStaticAddressLoopInSwap(ctx context.Context, swapHash []byt
|
|||
const getStaticAddressLoopInSwapsByStates = `-- name: GetStaticAddressLoopInSwapsByStates :many
|
||||
SELECT
|
||||
swaps.id, swaps.swap_hash, swaps.preimage, swaps.initiation_time, swaps.amount_requested, swaps.cltv_expiry, swaps.max_miner_fee, swaps.max_swap_fee, swaps.initiation_height, swaps.protocol_version, swaps.label,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast,
|
||||
static_address_swaps.id, static_address_swaps.swap_hash, static_address_swaps.swap_invoice, static_address_swaps.last_hop, static_address_swaps.payment_timeout_seconds, static_address_swaps.quoted_swap_fee_satoshis, static_address_swaps.deposit_outpoints, static_address_swaps.htlc_tx_fee_rate_sat_kw, static_address_swaps.htlc_timeout_sweep_tx_id, static_address_swaps.htlc_timeout_sweep_address, static_address_swaps.selected_amount, static_address_swaps.fast, static_address_swaps.confirmation_risk_decision, static_address_swaps.confirmation_risk_decision_time,
|
||||
htlc_keys.swap_hash, htlc_keys.sender_script_pubkey, htlc_keys.receiver_script_pubkey, htlc_keys.sender_internal_pubkey, htlc_keys.receiver_internal_pubkey, htlc_keys.client_key_family, htlc_keys.client_key_index
|
||||
FROM
|
||||
swaps
|
||||
|
|
@ -263,36 +267,38 @@ ORDER BY
|
|||
`
|
||||
|
||||
type GetStaticAddressLoopInSwapsByStatesRow struct {
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
ID int32
|
||||
SwapHash []byte
|
||||
Preimage []byte
|
||||
InitiationTime time.Time
|
||||
AmountRequested int64
|
||||
CltvExpiry int32
|
||||
MaxMinerFee int64
|
||||
MaxSwapFee int64
|
||||
InitiationHeight int32
|
||||
ProtocolVersion int32
|
||||
Label string
|
||||
ID_2 int32
|
||||
SwapHash_2 []byte
|
||||
SwapInvoice string
|
||||
LastHop []byte
|
||||
PaymentTimeoutSeconds int32
|
||||
QuotedSwapFeeSatoshis int64
|
||||
DepositOutpoints string
|
||||
HtlcTxFeeRateSatKw int64
|
||||
HtlcTimeoutSweepTxID sql.NullString
|
||||
HtlcTimeoutSweepAddress string
|
||||
SelectedAmount int64
|
||||
Fast bool
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
SwapHash_3 []byte
|
||||
SenderScriptPubkey []byte
|
||||
ReceiverScriptPubkey []byte
|
||||
SenderInternalPubkey []byte
|
||||
ReceiverInternalPubkey []byte
|
||||
ClientKeyFamily int32
|
||||
ClientKeyIndex int32
|
||||
}
|
||||
|
||||
func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dollar_1 sql.NullString) ([]GetStaticAddressLoopInSwapsByStatesRow, error) {
|
||||
|
|
@ -328,6 +334,8 @@ func (q *Queries) GetStaticAddressLoopInSwapsByStates(ctx context.Context, dolla
|
|||
&i.HtlcTimeoutSweepAddress,
|
||||
&i.SelectedAmount,
|
||||
&i.Fast,
|
||||
&i.ConfirmationRiskDecision,
|
||||
&i.ConfirmationRiskDecisionTime,
|
||||
&i.SwapHash_3,
|
||||
&i.SenderScriptPubkey,
|
||||
&i.ReceiverScriptPubkey,
|
||||
|
|
@ -482,6 +490,26 @@ func (q *Queries) OverrideSelectedSwapAmount(ctx context.Context, arg OverrideSe
|
|||
return err
|
||||
}
|
||||
|
||||
const recordStaticAddressRiskDecision = `-- name: RecordStaticAddressRiskDecision :exec
|
||||
UPDATE static_address_swaps
|
||||
SET
|
||||
confirmation_risk_decision = $2,
|
||||
confirmation_risk_decision_time = $3
|
||||
WHERE
|
||||
swap_hash = $1
|
||||
`
|
||||
|
||||
type RecordStaticAddressRiskDecisionParams struct {
|
||||
SwapHash []byte
|
||||
ConfirmationRiskDecision string
|
||||
ConfirmationRiskDecisionTime sql.NullTime
|
||||
}
|
||||
|
||||
func (q *Queries) RecordStaticAddressRiskDecision(ctx context.Context, arg RecordStaticAddressRiskDecisionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, recordStaticAddressRiskDecision, arg.SwapHash, arg.ConfirmationRiskDecision, arg.ConfirmationRiskDecisionTime)
|
||||
return err
|
||||
}
|
||||
|
||||
const swapHashForDepositID = `-- name: SwapHashForDepositID :one
|
||||
SELECT
|
||||
swap_hash
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -72,6 +80,13 @@ type Config struct {
|
|||
// MinAliveConnTime is the minimum time that the connection to the
|
||||
// server needs to be alive before we consider it a successful.
|
||||
MinAliveConnTime time.Duration
|
||||
|
||||
// PersistStaticLoopInRiskDecision durably records static loop-in
|
||||
// confirmation-risk decisions. If this fails, the notification is still
|
||||
// cached and forwarded so a later subscriber can process it after the swap
|
||||
// row exists.
|
||||
PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash,
|
||||
bool) error
|
||||
}
|
||||
|
||||
// Manager is a manager for notifications that the swap server sends to the
|
||||
|
|
@ -84,6 +99,12 @@ type Manager struct {
|
|||
hasL402 bool
|
||||
|
||||
subscribers map[NotificationType][]subscriber
|
||||
|
||||
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification
|
||||
|
||||
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager.
|
||||
|
|
@ -96,12 +117,115 @@ func NewManager(cfg *Config) *Manager {
|
|||
return &Manager{
|
||||
cfg: cfg,
|
||||
subscribers: make(map[NotificationType][]subscriber),
|
||||
staticLoopInRiskAccepted: make(
|
||||
map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification,
|
||||
),
|
||||
staticLoopInRiskRejected: make(
|
||||
map[lntypes.Hash]*swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type subscriber struct {
|
||||
subCtx context.Context
|
||||
recvChan any
|
||||
swapHash *lntypes.Hash
|
||||
enqueue func(any)
|
||||
}
|
||||
|
||||
// newNotificationQueue creates a per-subscriber FIFO delivery function.
|
||||
func newNotificationQueue[T any](ctx context.Context,
|
||||
recvChan chan T) func(any) {
|
||||
|
||||
type queue struct {
|
||||
sync.Mutex
|
||||
|
||||
pending []T
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
q := &queue{
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
log.Debugf("subscriber channel closed before " +
|
||||
"notification delivery")
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
q.Lock()
|
||||
if len(q.pending) == 0 {
|
||||
q.Unlock()
|
||||
|
||||
select {
|
||||
case <-q.notify:
|
||||
continue
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ntfn := q.pending[0]
|
||||
q.pending = q.pending[1:]
|
||||
q.Unlock()
|
||||
|
||||
select {
|
||||
case recvChan <- ntfn:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func(ntfn any) {
|
||||
typedNtfn, ok := ntfn.(T)
|
||||
if !ok {
|
||||
log.Warnf("unexpected notification type %T", ntfn)
|
||||
return
|
||||
}
|
||||
|
||||
q.Lock()
|
||||
q.pending = append(q.pending, typedNtfn)
|
||||
q.Unlock()
|
||||
|
||||
select {
|
||||
case q.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queueNotification queues or synchronously sends a must-deliver notification.
|
||||
func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
|
||||
if sub.enqueue != nil {
|
||||
sub.enqueue(ntfn)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case recvChan <- ntfn:
|
||||
case <-sub.subCtx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// dropNotification sends a best-effort notification to a subscriber.
|
||||
func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T,
|
||||
description string) {
|
||||
|
||||
select {
|
||||
case recvChan <- ntfn:
|
||||
case <-sub.subCtx.Done():
|
||||
default:
|
||||
log.Debugf("Dropping %s notification for slow subscriber",
|
||||
description)
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeReservations subscribes to the reservation notifications.
|
||||
|
|
@ -136,6 +260,7 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
|||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
enqueue: newNotificationQueue(ctx, notifChan),
|
||||
}
|
||||
|
||||
m.addSubscriber(NotificationTypeStaticLoopInSweepRequest, sub)
|
||||
|
|
@ -151,6 +276,80 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
|||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
|
||||
// notifications.
|
||||
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
|
||||
swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
notifChan := make(
|
||||
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1,
|
||||
)
|
||||
|
||||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
swapHash: &swapHash,
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append(
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub,
|
||||
)
|
||||
if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok {
|
||||
notifChan <- ntfn
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
context.AfterFunc(ctx, func() {
|
||||
m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
|
||||
m.Lock()
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
m.Unlock()
|
||||
close(notifChan)
|
||||
})
|
||||
|
||||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
|
||||
// notifications.
|
||||
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
|
||||
swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
|
||||
|
||||
notifChan := make(
|
||||
chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1,
|
||||
)
|
||||
|
||||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
swapHash: &swapHash,
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append(
|
||||
m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub,
|
||||
)
|
||||
if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok {
|
||||
notifChan <- ntfn
|
||||
delete(m.staticLoopInRiskRejected, swapHash)
|
||||
}
|
||||
m.Unlock()
|
||||
|
||||
context.AfterFunc(ctx, func() {
|
||||
m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub)
|
||||
m.Lock()
|
||||
delete(m.staticLoopInRiskRejected, swapHash)
|
||||
m.Unlock()
|
||||
close(notifChan)
|
||||
})
|
||||
|
||||
return notifChan
|
||||
}
|
||||
|
||||
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
|
||||
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
|
||||
|
|
@ -161,6 +360,7 @@ func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
|
|||
sub := subscriber{
|
||||
subCtx: ctx,
|
||||
recvChan: notifChan,
|
||||
enqueue: newNotificationQueue(ctx, notifChan),
|
||||
}
|
||||
|
||||
m.addSubscriber(NotificationTypeUnfinishedSwap, sub)
|
||||
|
|
@ -306,7 +506,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
|
||||
}
|
||||
|
||||
|
|
@ -316,9 +516,69 @@ func (m *Manager) subscribeNotifications(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskDecisionName returns the log label for a risk decision.
|
||||
func staticLoopInRiskDecisionName(accepted bool) string {
|
||||
if accepted {
|
||||
return "accepted"
|
||||
}
|
||||
|
||||
return "rejected"
|
||||
}
|
||||
|
||||
// handleStaticLoopInRiskDecision persists, caches, and forwards a risk
|
||||
// decision notification to the matching subscriber.
|
||||
func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context,
|
||||
swapHashBytes []byte, accepted bool, notifType NotificationType,
|
||||
cacheDecision func(lntypes.Hash), notifySubscriber func(subscriber)) {
|
||||
|
||||
decision := staticLoopInRiskDecisionName(accepted)
|
||||
|
||||
var (
|
||||
swapHash lntypes.Hash
|
||||
hasSwapHash bool
|
||||
)
|
||||
if swapHashBytes != nil {
|
||||
hash, err := lntypes.MakeHash(swapHashBytes)
|
||||
if err != nil {
|
||||
log.Warnf("Received invalid static loop in risk "+
|
||||
"%s notification: %v", decision, err)
|
||||
} else {
|
||||
swapHash = hash
|
||||
hasSwapHash = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil {
|
||||
err := m.cfg.PersistStaticLoopInRiskDecision(
|
||||
ctx, swapHash, accepted,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("Unable to persist static loop in risk "+
|
||||
"%s notification: %v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
if hasSwapHash {
|
||||
cacheDecision(swapHash)
|
||||
}
|
||||
|
||||
for _, sub := range m.subscribers[notifType] {
|
||||
if !hasSwapHash || sub.swapHash == nil ||
|
||||
*sub.swapHash != swapHash {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
notifySubscriber(sub)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNotification handles an incoming notification from the server,
|
||||
// forwarding it to the appropriate subscribers.
|
||||
func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
||||
func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc.
|
||||
SubscribeNotificationsResponse) {
|
||||
|
||||
switch ntfn.Notification.(type) {
|
||||
|
|
@ -332,7 +592,13 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
|||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerReservationNotification)
|
||||
|
||||
recvChan <- reservationNtfn
|
||||
select {
|
||||
case recvChan <- reservationNtfn:
|
||||
case <-sub.subCtx.Done():
|
||||
default:
|
||||
log.Debugf("Dropping reservation " +
|
||||
"notification for slow subscriber")
|
||||
}
|
||||
}
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInSweep: // nolint: lll
|
||||
// We'll forward the static loop in sweep request to all
|
||||
|
|
@ -345,9 +611,63 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
|||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInSweepNotification)
|
||||
|
||||
recvChan <- staticLoopInSweepRequestNtfn
|
||||
queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn)
|
||||
}
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
|
||||
// We'll forward the static loop in risk accepted notification to the
|
||||
// subscriber for the matching swap.
|
||||
riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
|
||||
var swapHashBytes []byte
|
||||
if riskAcceptedNtfn != nil {
|
||||
swapHashBytes = riskAcceptedNtfn.SwapHash
|
||||
}
|
||||
|
||||
m.handleStaticLoopInRiskDecision(
|
||||
ctx, swapHashBytes, true,
|
||||
NotificationTypeStaticLoopInRiskAccepted,
|
||||
func(swapHash lntypes.Hash) {
|
||||
m.staticLoopInRiskAccepted[swapHash] =
|
||||
riskAcceptedNtfn
|
||||
delete(m.staticLoopInRiskRejected, swapHash)
|
||||
},
|
||||
func(sub subscriber) {
|
||||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification)
|
||||
dropNotification(
|
||||
sub, recvChan, riskAcceptedNtfn,
|
||||
"static loop in risk accepted",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll
|
||||
// We'll forward the static loop in risk rejected notification to the
|
||||
// subscriber for the matching swap.
|
||||
riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected()
|
||||
var swapHashBytes []byte
|
||||
if riskRejectedNtfn != nil {
|
||||
swapHashBytes = riskRejectedNtfn.SwapHash
|
||||
}
|
||||
|
||||
m.handleStaticLoopInRiskDecision(
|
||||
ctx, swapHashBytes, false,
|
||||
NotificationTypeStaticLoopInRiskRejected,
|
||||
func(swapHash lntypes.Hash) {
|
||||
m.staticLoopInRiskRejected[swapHash] =
|
||||
riskRejectedNtfn
|
||||
delete(m.staticLoopInRiskAccepted, swapHash)
|
||||
},
|
||||
func(sub subscriber) {
|
||||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification)
|
||||
dropNotification(
|
||||
sub, recvChan, riskRejectedNtfn,
|
||||
"static loop in risk rejected",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
|
||||
// We'll forward the unfinished swap notification to all
|
||||
// subscribers.
|
||||
|
|
@ -359,7 +679,7 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
|
|||
recvChan := sub.recvChan.(chan *swapserverrpc.
|
||||
ServerUnfinishedSwapNotification)
|
||||
|
||||
recvChan <- unfinishedSwapNtfn
|
||||
queueNotification(sub, recvChan, unfinishedSwapNtfn)
|
||||
}
|
||||
|
||||
case *swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed:
|
||||
|
|
@ -403,7 +723,7 @@ func (m *Manager) removeSubscriber(notifType NotificationType, sub subscriber) {
|
|||
subs := m.subscribers[notifType]
|
||||
newSubs := make([]subscriber, 0, len(subs))
|
||||
for _, s := range subs {
|
||||
if s != sub {
|
||||
if s.recvChan != sub.recvChan {
|
||||
newSubs = append(newSubs, s)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import (
|
|||
|
||||
var (
|
||||
testReservationId = []byte{0x01, 0x02}
|
||||
testReservationId2 = []byte{0x01, 0x02}
|
||||
testReservationId2 = []byte{0x03, 0x04}
|
||||
)
|
||||
|
||||
// mockNotificationsClient implements the NotificationsClient interface for testing.
|
||||
|
|
@ -190,6 +190,527 @@ func getTestNotification(resId []byte) *swapserverrpc.SubscribeNotificationsResp
|
|||
}
|
||||
}
|
||||
|
||||
// unfinishedSwapNotification builds an unfinished swap notification.
|
||||
func unfinishedSwapNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_UnfinishedSwap{
|
||||
UnfinishedSwap: &swapserverrpc.
|
||||
ServerUnfinishedSwapNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// staticLoopInSweepNotification builds a static loop-in sweep notification.
|
||||
func staticLoopInSweepNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInSweep{
|
||||
StaticLoopInSweep: &swapserverrpc.
|
||||
ServerStaticLoopInSweepNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskAcceptedNotification builds a risk accepted notification.
|
||||
func staticLoopInRiskAcceptedNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// staticLoopInRiskRejectedNotification builds a risk rejected notification.
|
||||
func staticLoopInRiskRejectedNotification(
|
||||
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
|
||||
|
||||
return &swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type staticLoopInRiskNotification interface {
|
||||
GetSwapHash() []byte
|
||||
}
|
||||
|
||||
// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout.
|
||||
func assertStaticLoopInRiskNotificationSwapScoped[
|
||||
T staticLoopInRiskNotification](t *testing.T,
|
||||
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
|
||||
notification func(lntypes.Hash) *swapserverrpc.
|
||||
SubscribeNotificationsResponse, label string,
|
||||
swapHashA, swapHashB lntypes.Hash) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChanA := subscribe(mgr, subCtx, swapHashA)
|
||||
subChanB := subscribe(mgr, subCtx, swapHashB)
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHashA))
|
||||
|
||||
select {
|
||||
case received := <-subChanA:
|
||||
require.Equal(t, swapHashA[:], received.GetSwapHash())
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("did not receive first swap risk %s notification",
|
||||
label)
|
||||
}
|
||||
|
||||
select {
|
||||
case received := <-subChanB:
|
||||
t.Fatalf("second swap received wrong notification: %x",
|
||||
received.GetSwapHash())
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHashB))
|
||||
|
||||
select {
|
||||
case received := <-subChanB:
|
||||
require.Equal(t, swapHashB[:], received.GetSwapHash())
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("did not receive second swap risk %s notification",
|
||||
label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_SlowSubscriberDoesNotBlock tests that a subscriber with a full
|
||||
// notification channel does not block delivery to other subscribers.
|
||||
func TestManager_SlowSubscriberDoesNotBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
slowCtx, slowCancel := context.WithCancel(t.Context())
|
||||
defer slowCancel()
|
||||
slowChan := mgr.SubscribeReservations(slowCtx)
|
||||
|
||||
fastCtx, fastCancel := context.WithCancel(t.Context())
|
||||
defer fastCancel()
|
||||
fastChan := mgr.SubscribeReservations(fastCtx)
|
||||
|
||||
firstNotif := getTestNotification(testReservationId)
|
||||
mgr.handleNotification(t.Context(), firstNotif)
|
||||
|
||||
received := <-fastChan
|
||||
require.Equal(t, testReservationId, received.ReservationId)
|
||||
|
||||
secondNotif := getTestNotification(testReservationId2)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mgr.handleNotification(t.Context(), secondNotif)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
select {
|
||||
case received = <-fastChan:
|
||||
require.Equal(t, testReservationId2, received.ReservationId)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fast subscriber did not receive notification")
|
||||
}
|
||||
|
||||
require.Len(t, slowChan, 1)
|
||||
}
|
||||
|
||||
// TestManager_UnfinishedSwapNotificationWaitsForSubscriber verifies that
|
||||
// unfinished swap recovery notifications are not dropped when the local
|
||||
// subscriber is briefly behind.
|
||||
func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertQueuedSwapHashNotifications(
|
||||
t,
|
||||
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
|
||||
ServerUnfinishedSwapNotification {
|
||||
|
||||
return mgr.SubscribeUnfinishedSwaps(ctx)
|
||||
},
|
||||
unfinishedSwapNotification,
|
||||
func(ntfn *swapserverrpc.ServerUnfinishedSwapNotification) []byte {
|
||||
return ntfn.SwapHash
|
||||
},
|
||||
lntypes.Hash{0x02, 0x03}, lntypes.Hash{0x04, 0x05},
|
||||
"did not receive first unfinished swap notification",
|
||||
"second unfinished swap notification was dropped",
|
||||
)
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber verifies
|
||||
// that a full static-loop-in sweep subscriber channel does not block the global
|
||||
// notification receive loop.
|
||||
func TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber(
|
||||
t *testing.T) {
|
||||
|
||||
t.Parallel()
|
||||
|
||||
assertQueuedSwapHashNotifications(
|
||||
t,
|
||||
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
|
||||
ServerStaticLoopInSweepNotification {
|
||||
|
||||
return mgr.SubscribeStaticLoopInSweepRequests(ctx)
|
||||
},
|
||||
staticLoopInSweepNotification,
|
||||
func(ntfn *swapserverrpc.ServerStaticLoopInSweepNotification) []byte {
|
||||
return ntfn.SwapHash
|
||||
},
|
||||
lntypes.Hash{0x12, 0x13}, lntypes.Hash{0x14, 0x15},
|
||||
"did not receive first sweep notification",
|
||||
"second sweep notification was not queued",
|
||||
)
|
||||
}
|
||||
|
||||
// assertQueuedSwapHashNotifications checks queued delivery for swap hashes.
|
||||
func assertQueuedSwapHashNotifications[T any](t *testing.T,
|
||||
subscribe func(*Manager, context.Context) <-chan T,
|
||||
notification func(lntypes.Hash) *swapserverrpc.
|
||||
SubscribeNotificationsResponse,
|
||||
swapHash func(T) []byte, swapHashA, swapHashB lntypes.Hash,
|
||||
firstFailureMsg, secondFailureMsg string) {
|
||||
|
||||
t.Helper()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := subscribe(mgr, subCtx)
|
||||
|
||||
mgr.handleNotification(t.Context(), notification(swapHashA))
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
mgr.handleNotification(t.Context(), notification(swapHashB))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHashA[:], swapHash(received))
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal(firstFailureMsg)
|
||||
}
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHashB[:], swapHash(received))
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal(secondFailureMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
|
||||
// forwards static loop in risk accepted notifications to subscribers.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
swapHash := lntypes.Hash{0x04, 0x05}
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are
|
||||
// handed to the durable callback before they are treated as delivered.
|
||||
func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type persistedDecision struct {
|
||||
swapHash lntypes.Hash
|
||||
accepted bool
|
||||
}
|
||||
|
||||
persisted := make(chan persistedDecision, 2)
|
||||
mgr := NewManager(&Config{
|
||||
PersistStaticLoopInRiskDecision: func(_ context.Context,
|
||||
swapHash lntypes.Hash, accepted bool) error {
|
||||
|
||||
persisted <- persistedDecision{
|
||||
swapHash: swapHash,
|
||||
accepted: accepted,
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
acceptedHash := lntypes.Hash{0x16, 0x17}
|
||||
rejectedHash := lntypes.Hash{0x18, 0x19}
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash),
|
||||
)
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskRejectedNotification(rejectedHash),
|
||||
)
|
||||
|
||||
select {
|
||||
case decision := <-persisted:
|
||||
require.Equal(t, acceptedHash, decision.swapHash)
|
||||
require.True(t, decision.accepted)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("accepted risk decision was not persisted")
|
||||
}
|
||||
|
||||
select {
|
||||
case decision := <-persisted:
|
||||
require.Equal(t, rejectedHash, decision.swapHash)
|
||||
require.False(t, decision.accepted)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("rejected risk decision was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an
|
||||
// early risk notification is still cached if the swap row does not exist yet.
|
||||
func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
swapHash := lntypes.Hash{0x1a, 0x1b}
|
||||
mgr := NewManager(&Config{
|
||||
PersistStaticLoopInRiskDecision: func(_ context.Context,
|
||||
_ lntypes.Hash, _ bool) error {
|
||||
|
||||
return errors.New("swap not stored yet")
|
||||
},
|
||||
})
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(), staticLoopInRiskAcceptedNotification(swapHash),
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk notification after persist failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a
|
||||
// notification for one swap does not occupy another swap's subscriber channel.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertStaticLoopInRiskNotificationSwapScoped(
|
||||
t, func(m *Manager, ctx context.Context,
|
||||
swapHash lntypes.Hash) <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification {
|
||||
|
||||
return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash)
|
||||
}, staticLoopInRiskAcceptedNotification, "accepted",
|
||||
lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07},
|
||||
)
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
|
||||
// replays a risk accepted notification that arrives before the swap-specific
|
||||
// subscriber is registered.
|
||||
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
swapHash := lntypes.Hash{0x06, 0x07}
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
|
||||
StaticLoopInRiskAccepted: &swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk accepted notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager
|
||||
// forwards static loop in risk rejected notifications to subscribers.
|
||||
func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
swapHash := lntypes.Hash{0x08, 0x09}
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
|
||||
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not receive risk rejected notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a
|
||||
// notification for one swap does not occupy another swap's subscriber channel.
|
||||
func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertStaticLoopInRiskNotificationSwapScoped(
|
||||
t, func(m *Manager, ctx context.Context,
|
||||
swapHash lntypes.Hash) <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification {
|
||||
|
||||
return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash)
|
||||
}, staticLoopInRiskRejectedNotification, "rejected",
|
||||
lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b},
|
||||
)
|
||||
}
|
||||
|
||||
// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager
|
||||
// replays a risk rejected notification that arrives before the swap-specific
|
||||
// subscriber is registered.
|
||||
func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := NewManager(&Config{})
|
||||
|
||||
swapHash := lntypes.Hash{0x0a, 0x0b}
|
||||
mgr.handleNotification(
|
||||
t.Context(),
|
||||
&swapserverrpc.SubscribeNotificationsResponse{
|
||||
Notification: &swapserverrpc.
|
||||
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
|
||||
StaticLoopInRiskRejected: &swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification{
|
||||
SwapHash: swapHash[:],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
subCtx, subCancel := context.WithCancel(t.Context())
|
||||
defer subCancel()
|
||||
|
||||
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
|
||||
|
||||
select {
|
||||
case received := <-subChan:
|
||||
require.Equal(t, swapHash[:], received.SwapHash)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("did not replay risk rejected notification")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManager_Backoff verifies that repeated failures in
|
||||
// subscribeNotifications cause the Manager to space out subscription attempts
|
||||
// via a predictable incremental backoff.
|
||||
|
|
|
|||
|
|
@ -161,14 +161,25 @@ func (f *FSM) WaitForExpirySweepAction(ctx context.Context,
|
|||
|
||||
// FinalizeDepositAction is the final action after a withdrawal. It signals to
|
||||
// the manager that the deposit has been swept and the FSM can be removed.
|
||||
func (f *FSM) FinalizeDepositAction(ctx context.Context,
|
||||
func (f *FSM) FinalizeDepositAction(_ context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fsm.OnError
|
||||
outpoint := f.deposit.OutPoint
|
||||
|
||||
case f.finalizedDepositChan <- f.deposit.OutPoint:
|
||||
return fsm.NoOp
|
||||
}
|
||||
// The finalization notification only tells the manager to remove the
|
||||
// deposit from its active set. Send it asynchronously so a busy manager
|
||||
// loop can't stall withdrawal confirmation while deposit locks are held.
|
||||
go func() {
|
||||
select {
|
||||
case <-f.quitChan:
|
||||
// The deposit is already in a final state. If shutdown wins
|
||||
// this race, startup recovery will skip it instead of
|
||||
// re-adding it to the active set.
|
||||
return
|
||||
|
||||
case f.finalizedDepositChan <- outpoint:
|
||||
}
|
||||
}()
|
||||
|
||||
return fsm.NoOp
|
||||
}
|
||||
|
|
|
|||
135
staticaddr/deposit/actions_test.go
Normal file
135
staticaddr/deposit/actions_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/loop/fsm"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestFinalizeDepositActionDoesNotBlock ensures the final cleanup notification
|
||||
// does not block the withdrawal completion path while the manager loop is busy.
|
||||
func TestFinalizeDepositActionDoesNotBlock(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{1},
|
||||
Index: 1,
|
||||
}
|
||||
|
||||
depositFSM := &FSM{
|
||||
deposit: &Deposit{
|
||||
OutPoint: outpoint,
|
||||
},
|
||||
quitChan: make(chan struct{}),
|
||||
finalizedDepositChan: make(chan wire.OutPoint),
|
||||
}
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
require.Equal(t, fsm.NoOp, result)
|
||||
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
|
||||
}
|
||||
|
||||
select {
|
||||
case gotOutpoint := <-depositFSM.finalizedDepositChan:
|
||||
require.Equal(t, outpoint, gotOutpoint)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("finalization cleanup notification was not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalizeDepositActionIgnoresRequestCancellation ensures the cleanup
|
||||
// notification is tied to the FSM lifetime, not the caller's request context.
|
||||
func TestFinalizeDepositActionIgnoresRequestCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
quitChan := make(chan struct{})
|
||||
defer close(quitChan)
|
||||
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{2},
|
||||
Index: 2,
|
||||
}
|
||||
|
||||
depositFSM := &FSM{
|
||||
deposit: &Deposit{
|
||||
OutPoint: outpoint,
|
||||
},
|
||||
quitChan: quitChan,
|
||||
finalizedDepositChan: make(chan wire.OutPoint),
|
||||
}
|
||||
|
||||
resultChan := make(chan fsm.EventType, 1)
|
||||
go func() {
|
||||
resultChan <- depositFSM.FinalizeDepositAction(ctx, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-resultChan:
|
||||
require.Equal(t, fsm.NoOp, result)
|
||||
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("FinalizeDepositAction blocked on manager cleanup")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case gotOutpoint := <-depositFSM.finalizedDepositChan:
|
||||
require.Equal(t, outpoint, gotOutpoint)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("finalization cleanup notification was dropped after " +
|
||||
"request cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinalizeDepositActionIgnoresCanceledContext ensures the final cleanup
|
||||
// notification is still queued even if the caller's context is already done.
|
||||
func TestFinalizeDepositActionIgnoresCanceledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
quitChan := make(chan struct{})
|
||||
defer close(quitChan)
|
||||
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{3},
|
||||
Index: 3,
|
||||
}
|
||||
|
||||
depositFSM := &FSM{
|
||||
deposit: &Deposit{
|
||||
OutPoint: outpoint,
|
||||
},
|
||||
quitChan: quitChan,
|
||||
finalizedDepositChan: make(chan wire.OutPoint),
|
||||
}
|
||||
|
||||
result := depositFSM.FinalizeDepositAction(ctx, nil)
|
||||
require.Equal(t, fsm.NoOp, result)
|
||||
|
||||
select {
|
||||
case gotOutpoint := <-depositFSM.finalizedDepositChan:
|
||||
require.Equal(t, outpoint, gotOutpoint)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("finalization cleanup notification was dropped for " +
|
||||
"an already-canceled request context")
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,10 @@ func (r *ID) FromByteSlice(b []byte) error {
|
|||
|
||||
// Deposit bundles an utxo at a static address together with manager-relevant
|
||||
// data.
|
||||
//
|
||||
// Lock order: if both Manager.mu and a Deposit lock are needed, acquire
|
||||
// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a
|
||||
// Deposit lock.
|
||||
type Deposit struct {
|
||||
sync.Mutex
|
||||
|
||||
|
|
@ -45,7 +49,8 @@ type Deposit struct {
|
|||
Value btcutil.Amount
|
||||
|
||||
// ConfirmationHeight is the absolute height at which the deposit was
|
||||
// first confirmed.
|
||||
// first confirmed. A value of zero means the deposit is still
|
||||
// unconfirmed.
|
||||
ConfirmationHeight int64
|
||||
|
||||
// TimeOutSweepPkScript is the pk script that is used to sweep the
|
||||
|
|
@ -69,15 +74,22 @@ func (d *Deposit) IsInFinalState() bool {
|
|||
d.Lock()
|
||||
defer d.Unlock()
|
||||
|
||||
// Replaced is inactive from the deposit FSM's point of view. The manager may
|
||||
// still revive the same record if lnd reports the exact outpoint again after
|
||||
// a transient wallet-view miss.
|
||||
return d.state == Expired || d.state == Withdrawn ||
|
||||
d.state == LoopedIn || d.state == HtlcTimeoutSwept ||
|
||||
d.state == ChannelPublished
|
||||
d.state == ChannelPublished || d.state == Replaced
|
||||
}
|
||||
|
||||
func (d *Deposit) IsExpired(currentHeight, expiry uint32) bool {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentHeight >= uint32(d.ConfirmationHeight)+expiry
|
||||
}
|
||||
|
||||
|
|
|
|||
15
staticaddr/deposit/deposit_test.go
Normal file
15
staticaddr/deposit/deposit_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package deposit
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsExpiredUnconfirmed checks that unconfirmed deposits don't start their
|
||||
// expiry timer.
|
||||
func TestIsExpiredUnconfirmed(t *testing.T) {
|
||||
deposit := &Deposit{
|
||||
ConfirmationHeight: 0,
|
||||
}
|
||||
|
||||
if deposit.IsExpired(500, 100) {
|
||||
t.Fatal("unconfirmed deposit should not be expired")
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
|
|
@ -41,10 +42,26 @@ var (
|
|||
|
||||
// States.
|
||||
var (
|
||||
// Deposited signals that funds at a static address have reached the
|
||||
// confirmation height.
|
||||
// Deposited signals that funds at a static address have been detected
|
||||
// and are available to the client.
|
||||
Deposited = fsm.StateType("Deposited")
|
||||
|
||||
// Replaced signals that a deposit disappeared from the wallet view and
|
||||
// can no longer be spent.
|
||||
//
|
||||
// The concrete case we need to handle is mempool replacement: a user can
|
||||
// receive to the static address, we persist that unconfirmed outpoint, and
|
||||
// then the funding transaction can be replaced or otherwise evicted before
|
||||
// confirmation. Once that happens lnd stops returning the old outpoint from
|
||||
// ListUnspent, but our DB would otherwise keep presenting it as selectable.
|
||||
// Replaced lets us retain the historic record while making it clear that the
|
||||
// original outpoint is no longer a live deposit.
|
||||
//
|
||||
// This state is managed directly by the deposit manager rather than via
|
||||
// DepositStatesV0 because it reflects wallet visibility changes such as
|
||||
// mempool replacement or deep reorgs, not an FSM-driven spend path.
|
||||
Replaced = fsm.StateType("Replaced")
|
||||
|
||||
// Withdrawing signals that the withdrawal transaction has been
|
||||
// broadcast, awaiting sufficient confirmations.
|
||||
Withdrawing = fsm.StateType("Withdrawing")
|
||||
|
|
@ -92,8 +109,8 @@ var (
|
|||
// Events.
|
||||
var (
|
||||
// OnStart is sent to the fsm once the deposit outpoint has been
|
||||
// sufficiently confirmed. It transitions the fsm into the Deposited
|
||||
// state from where we can trigger a withdrawal, a loopin or an expiry.
|
||||
// detected. It transitions the fsm into the Deposited state from where
|
||||
// we can trigger a withdrawal, a loopin or an expiry.
|
||||
OnStart = fsm.EventType("OnStart")
|
||||
|
||||
// OnWithdrawInitiated is sent to the fsm when a withdrawal has been
|
||||
|
|
@ -160,12 +177,17 @@ type FSM struct {
|
|||
|
||||
blockNtfnChan chan uint32
|
||||
|
||||
// stopChan requests shutdown of the block notification loop.
|
||||
stopChan chan struct{}
|
||||
|
||||
// quitChan stops after the FSM stops consuming blockNtfnChan.
|
||||
quitChan chan struct{}
|
||||
|
||||
// finalizedDepositChan is used to signal that the deposit has been
|
||||
// finalized and the FSM can be removed from the manager's memory.
|
||||
finalizedDepositChan chan wire.OutPoint
|
||||
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewFSM creates a new state machine that can action on all static address
|
||||
|
|
@ -191,6 +213,7 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
|
|||
params: params,
|
||||
address: address,
|
||||
blockNtfnChan: make(chan uint32),
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
finalizedDepositChan: finalizedDepositChan,
|
||||
}
|
||||
|
|
@ -226,6 +249,9 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
|
|||
ctx, currentHeight,
|
||||
)
|
||||
|
||||
case <-fsm.stopChan:
|
||||
return
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
|
@ -235,6 +261,17 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig,
|
|||
return depoFsm, nil
|
||||
}
|
||||
|
||||
// Stop requests shutdown of the FSM's block notification loop.
|
||||
func (f *FSM) Stop() {
|
||||
if f == nil || f.stopChan == nil {
|
||||
return
|
||||
}
|
||||
|
||||
f.stopOnce.Do(func() {
|
||||
close(f.stopChan)
|
||||
})
|
||||
}
|
||||
|
||||
// handleBlockNotification inspects the current block height and sends the
|
||||
// OnExpiry event to publish the expiry sweep transaction if the deposit timed
|
||||
// out, or it republishes the expiry sweep transaction if it was not yet swept.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
|
|
@ -17,9 +18,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// MinConfs is the minimum number of confirmations we require for a
|
||||
// deposit to be considered available for loop-ins, coop-spends and
|
||||
// timeouts.
|
||||
// MinConfs is the legacy minimum confirmation target deposits had to
|
||||
// reach before they were considered ready to be used for swaps.
|
||||
MinConfs = 6
|
||||
|
||||
// MaxConfs is unset since we don't require a max number of
|
||||
|
|
@ -33,6 +33,17 @@ const (
|
|||
// PollInterval is the interval in which we poll for new deposits to our
|
||||
// static address.
|
||||
PollInterval = 10 * time.Second
|
||||
|
||||
// vanishedDepositThreshold is the number of consecutive wallet
|
||||
// observations in which a Deposited outpoint must be missing before we
|
||||
// mark it replaced.
|
||||
//
|
||||
// A single miss can happen during a transient wallet-view gap while lnd is
|
||||
// processing a replacement or reorg. Requiring two misses keeps that narrow
|
||||
// race recoverable without leaving vanished deposits selectable forever. At
|
||||
// the default PollInterval, this means a vanished deposit can remain active
|
||||
// for up to roughly 20 seconds.
|
||||
vanishedDepositThreshold = 2
|
||||
)
|
||||
|
||||
// ManagerConfig holds the configuration for the address manager.
|
||||
|
|
@ -41,6 +52,10 @@ type ManagerConfig struct {
|
|||
// address parameters.
|
||||
AddressManager AddressManager
|
||||
|
||||
// ChainKit is used to query the best known chain tip when deriving
|
||||
// confirmation heights from wallet UTXOs.
|
||||
ChainKit lndclient.ChainKitClient
|
||||
|
||||
// Store is the database store that is used to store static address
|
||||
// related records.
|
||||
Store Store
|
||||
|
|
@ -58,15 +73,27 @@ type ManagerConfig struct {
|
|||
}
|
||||
|
||||
// Manager manages the address state machines.
|
||||
//
|
||||
// Lock order: if both Manager.mu and a Deposit lock are needed, acquire
|
||||
// Manager.mu before Deposit.Lock. Never acquire Manager.mu while holding a
|
||||
// Deposit lock.
|
||||
type Manager struct {
|
||||
cfg *ManagerConfig
|
||||
|
||||
// mu guards access to the activeDeposits map.
|
||||
mu sync.Mutex
|
||||
|
||||
// reconcileMu serializes deposit reconciliation so new deposits are
|
||||
// discovered and retained exactly once per outpoint.
|
||||
reconcileMu sync.Mutex
|
||||
|
||||
// activeDeposits contains all the active static address outputs.
|
||||
activeDeposits map[wire.OutPoint]*FSM
|
||||
|
||||
// missingDeposits counts consecutive wallet observations in which a
|
||||
// Deposited outpoint was missing from the wallet view.
|
||||
missingDeposits map[wire.OutPoint]uint8
|
||||
|
||||
// deposits contain all the deposits that have ever been made to the
|
||||
// static address. This field is used to store and recover deposits. It
|
||||
// also serves as a basis for reconciliation of newly detected deposits
|
||||
|
|
@ -77,6 +104,9 @@ type Manager struct {
|
|||
// been finalized. The manager will adjust its internal state and flush
|
||||
// finalized deposits from its memory.
|
||||
finalizedDepositChan chan wire.OutPoint
|
||||
|
||||
// currentHeight stores the currently best known block height.
|
||||
currentHeight atomic.Uint32
|
||||
}
|
||||
|
||||
// NewManager creates a new deposit manager.
|
||||
|
|
@ -84,6 +114,7 @@ func NewManager(cfg *ManagerConfig) *Manager {
|
|||
return &Manager{
|
||||
cfg: cfg,
|
||||
activeDeposits: make(map[wire.OutPoint]*FSM),
|
||||
missingDeposits: make(map[wire.OutPoint]uint8),
|
||||
deposits: make(map[wire.OutPoint]*Deposit),
|
||||
finalizedDepositChan: make(chan wire.OutPoint),
|
||||
}
|
||||
|
|
@ -98,6 +129,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
var startupHeight uint32
|
||||
select {
|
||||
case height := <-newBlockChan:
|
||||
startupHeight = uint32(height)
|
||||
m.currentHeight.Store(startupHeight)
|
||||
|
||||
case err = <-newBlockErrChan:
|
||||
return err
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Recover previous deposits and static address parameters from the DB.
|
||||
err = m.recoverDeposits(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -113,6 +157,13 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
log.Errorf("unable to reconcile deposits: %v", err)
|
||||
}
|
||||
|
||||
// The startup height was consumed before recovered deposit FSMs existed.
|
||||
// Replay it so already-expired recovered deposits can act immediately.
|
||||
err = m.notifyActiveDeposits(ctx, startupHeight)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the deposit notifier.
|
||||
m.pollDeposits(ctx)
|
||||
|
||||
|
|
@ -123,32 +174,22 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
for {
|
||||
select {
|
||||
case height := <-newBlockChan:
|
||||
// Inform all active deposits about a new block arrival.
|
||||
m.mu.Lock()
|
||||
activeDeposits := make([]*FSM, 0, len(m.activeDeposits))
|
||||
for _, fsm := range m.activeDeposits {
|
||||
activeDeposits = append(activeDeposits, fsm)
|
||||
m.currentHeight.Store(uint32(height))
|
||||
|
||||
err := m.reconcileDeposits(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("unable to reconcile deposits: %v", err)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, fsm := range activeDeposits {
|
||||
select {
|
||||
case fsm.blockNtfnChan <- uint32(height):
|
||||
|
||||
case <-fsm.quitChan:
|
||||
continue
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
err = m.notifyActiveDeposits(ctx, uint32(height))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case outpoint := <-m.finalizedDepositChan:
|
||||
// If deposits notify us about their finalization, flush
|
||||
// the finalized deposit from memory.
|
||||
m.mu.Lock()
|
||||
delete(m.activeDeposits, outpoint)
|
||||
m.mu.Unlock()
|
||||
m.removeActiveDeposit(outpoint)
|
||||
|
||||
case err = <-newBlockErrChan:
|
||||
return err
|
||||
|
|
@ -159,6 +200,33 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error {
|
|||
}
|
||||
}
|
||||
|
||||
// notifyActiveDeposits informs all active deposit FSMs about a new block
|
||||
// height.
|
||||
func (m *Manager) notifyActiveDeposits(ctx context.Context,
|
||||
height uint32) error {
|
||||
|
||||
m.mu.Lock()
|
||||
activeDeposits := make([]*FSM, 0, len(m.activeDeposits))
|
||||
for _, fsm := range m.activeDeposits {
|
||||
activeDeposits = append(activeDeposits, fsm)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, fsm := range activeDeposits {
|
||||
select {
|
||||
case fsm.blockNtfnChan <- height:
|
||||
|
||||
case <-fsm.quitChan:
|
||||
continue
|
||||
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoverDeposits recovers static address parameters, previous deposits and
|
||||
// state machines from the database and starts the deposit notifier.
|
||||
func (m *Manager) recoverDeposits(ctx context.Context) error {
|
||||
|
|
@ -207,8 +275,10 @@ func (m *Manager) recoverDeposits(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// pollDeposits polls new deposits to our static address and notifies the
|
||||
// manager's event loop about them.
|
||||
// pollDeposits periodically polls for new deposits to our static address. This
|
||||
// complements the block-driven reconciliation in the main event loop: while new
|
||||
// blocks trigger reconcileDeposits to promptly detect confirmations, the ticker
|
||||
// here catches deposits that appear in the mempool between blocks.
|
||||
func (m *Manager) pollDeposits(ctx context.Context) {
|
||||
log.Debugf("Waiting for new static address deposits...")
|
||||
|
||||
|
|
@ -236,13 +306,36 @@ func (m *Manager) pollDeposits(ctx context.Context) {
|
|||
// far. It picks the newly identified deposits and starts a state machine per
|
||||
// deposit to track its progress.
|
||||
func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
||||
m.reconcileMu.Lock()
|
||||
defer m.reconcileMu.Unlock()
|
||||
|
||||
log.Tracef("Reconciling new deposits...")
|
||||
|
||||
utxos, err := m.cfg.AddressManager.ListUnspent(
|
||||
ctx, MinConfs, MaxConfs,
|
||||
)
|
||||
utxos, bestHeight, err := m.listUnspentWithBestHeight(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to list new deposits: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateDepositConfirmations(ctx, utxos, bestHeight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to update deposit "+
|
||||
"confirmations: %w", err)
|
||||
}
|
||||
|
||||
// If the same outpoint reappeared after a transient wallet-view miss,
|
||||
// reactivate the existing record before we consider it new or vanished.
|
||||
err = m.reviveReappearedDeposits(ctx, utxos, bestHeight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to revive reappeared deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
// After handling reappearances, only still-missing outpoints contribute
|
||||
// towards replacement detection.
|
||||
err = m.invalidateVanishedDeposits(ctx, utxos)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to invalidate vanished "+
|
||||
"deposits: %w", err)
|
||||
}
|
||||
|
||||
newDeposits := m.filterNewDeposits(utxos)
|
||||
|
|
@ -252,7 +345,7 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
|||
}
|
||||
|
||||
for _, utxo := range newDeposits {
|
||||
deposit, err := m.createNewDeposit(ctx, utxo)
|
||||
deposit, err := m.createNewDeposit(ctx, utxo, bestHeight)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retain new deposit: %w",
|
||||
err)
|
||||
|
|
@ -269,12 +362,71 @@ func (m *Manager) reconcileDeposits(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// listUnspentWithBestHeight returns the wallet's current static-address UTXOs
|
||||
// together with a stable chain tip height for any confirmed outputs.
|
||||
func (m *Manager) listUnspentWithBestHeight(ctx context.Context) (
|
||||
[]*lnwallet.Utxo, int32, error) {
|
||||
|
||||
utxos, err := m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to list new deposits: %w", err)
|
||||
}
|
||||
|
||||
needsBestHeight := false
|
||||
for _, utxo := range utxos {
|
||||
if utxo.Confirmations > 0 {
|
||||
needsBestHeight = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !needsBestHeight {
|
||||
return utxos, 0, nil
|
||||
}
|
||||
|
||||
if m.cfg.ChainKit == nil {
|
||||
return nil, 0, errors.New("chain kit client required for " +
|
||||
"confirmed deposits")
|
||||
}
|
||||
|
||||
const maxAttempts = 3
|
||||
for range maxAttempts {
|
||||
_, beforeHeight, err := m.cfg.ChainKit.GetBestBlock(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to get best block "+
|
||||
"before listing deposits: %w", err)
|
||||
}
|
||||
|
||||
utxos, err = m.cfg.AddressManager.ListUnspent(ctx, 0, MaxConfs)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to list new deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
_, afterHeight, err := m.cfg.ChainKit.GetBestBlock(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("unable to get best block "+
|
||||
"after listing deposits: %w", err)
|
||||
}
|
||||
|
||||
if beforeHeight == afterHeight {
|
||||
m.currentHeight.Store(uint32(afterHeight))
|
||||
return utxos, afterHeight, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, errors.New("unable to get stable best block while " +
|
||||
"listing deposits")
|
||||
}
|
||||
|
||||
// createNewDeposit transforms the wallet utxo into a deposit struct and stores
|
||||
// it in our database and manager memory.
|
||||
func (m *Manager) createNewDeposit(ctx context.Context,
|
||||
utxo *lnwallet.Utxo) (*Deposit, error) {
|
||||
utxo *lnwallet.Utxo, bestHeight int32) (*Deposit, error) {
|
||||
|
||||
blockHeight, err := m.getBlockHeight(ctx, utxo)
|
||||
confirmationHeight, err := confirmationHeightForUtxo(
|
||||
bestHeight, utxo,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -302,7 +454,7 @@ func (m *Manager) createNewDeposit(ctx context.Context,
|
|||
state: Deposited,
|
||||
OutPoint: utxo.OutPoint,
|
||||
Value: utxo.Value,
|
||||
ConfirmationHeight: int64(blockHeight),
|
||||
ConfirmationHeight: confirmationHeight,
|
||||
TimeOutSweepPkScript: timeoutSweepPkScript,
|
||||
}
|
||||
|
||||
|
|
@ -318,37 +470,242 @@ func (m *Manager) createNewDeposit(ctx context.Context,
|
|||
return deposit, nil
|
||||
}
|
||||
|
||||
// getBlockHeight retrieves the block height of a given utxo.
|
||||
func (m *Manager) getBlockHeight(ctx context.Context,
|
||||
utxo *lnwallet.Utxo) (uint32, error) {
|
||||
// confirmationHeightForUtxo derives the first confirmation height of a wallet
|
||||
// UTXO from a stable best-known chain tip. Unconfirmed UTXOs return 0.
|
||||
func confirmationHeightForUtxo(bestHeight int32,
|
||||
utxo *lnwallet.Utxo) (int64, error) {
|
||||
|
||||
addressParams, err := m.cfg.AddressManager.GetStaticAddressParameters(
|
||||
ctx,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("couldn't get confirmation height for "+
|
||||
"deposit, %w", err)
|
||||
if utxo.Confirmations <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
notifChan, errChan, err :=
|
||||
m.cfg.ChainNotifier.RegisterConfirmationsNtfn(
|
||||
ctx, &utxo.OutPoint.Hash, addressParams.PkScript,
|
||||
MinConfs, addressParams.InitiationHeight,
|
||||
if bestHeight <= 0 {
|
||||
return 0, fmt.Errorf("invalid best height %d", bestHeight)
|
||||
}
|
||||
|
||||
firstConfirmationHeight := int64(bestHeight) - utxo.Confirmations + 1
|
||||
if firstConfirmationHeight <= 0 {
|
||||
return 0, fmt.Errorf("invalid confirmation height %d for %v "+
|
||||
"with best height %d and %d confirmations",
|
||||
firstConfirmationHeight, utxo.OutPoint, bestHeight,
|
||||
utxo.Confirmations)
|
||||
}
|
||||
|
||||
return firstConfirmationHeight, nil
|
||||
}
|
||||
|
||||
// updateDepositConfirmations backfills first confirmation heights for deposits
|
||||
// that were previously detected unconfirmed.
|
||||
func (m *Manager) updateDepositConfirmations(ctx context.Context,
|
||||
utxos []*lnwallet.Utxo, bestHeight int32) error {
|
||||
|
||||
for _, utxo := range utxos {
|
||||
if utxo.Confirmations <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
deposit, ok := m.deposits[utxo.OutPoint]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
deposit.Lock()
|
||||
if deposit.ConfirmationHeight > 0 {
|
||||
deposit.Unlock()
|
||||
continue
|
||||
}
|
||||
deposit.Unlock()
|
||||
|
||||
confirmationHeight, err := confirmationHeightForUtxo(
|
||||
bestHeight, utxo,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deposit.Lock()
|
||||
if deposit.ConfirmationHeight > 0 {
|
||||
deposit.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
previousConfirmationHeight := deposit.ConfirmationHeight
|
||||
deposit.ConfirmationHeight = confirmationHeight
|
||||
|
||||
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
|
||||
if err != nil {
|
||||
deposit.ConfirmationHeight = previousConfirmationHeight
|
||||
deposit.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
deposit.Unlock()
|
||||
}
|
||||
|
||||
select {
|
||||
case tx := <-notifChan:
|
||||
return tx.BlockHeight, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
case err := <-errChan:
|
||||
return 0, err
|
||||
// reviveReappearedDeposits reactivates deposits that were previously marked as
|
||||
// replaced if the exact same outpoint reappears in the wallet view.
|
||||
//
|
||||
// This is the inverse of invalidateVanishedDeposits: it lets us
|
||||
// recover from a transient ListUnspent gap without inventing a second record
|
||||
// for the same outpoint.
|
||||
func (m *Manager) reviveReappearedDeposits(ctx context.Context,
|
||||
utxos []*lnwallet.Utxo, bestHeight int32) error {
|
||||
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
type reviveCandidate struct {
|
||||
deposit *Deposit
|
||||
utxo *lnwallet.Utxo
|
||||
}
|
||||
|
||||
var candidates []reviveCandidate
|
||||
|
||||
m.mu.Lock()
|
||||
for _, utxo := range utxos {
|
||||
delete(m.missingDeposits, utxo.OutPoint)
|
||||
|
||||
deposit, ok := m.deposits[utxo.OutPoint]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, active := m.activeDeposits[utxo.OutPoint]; active {
|
||||
continue
|
||||
}
|
||||
|
||||
deposit.Lock()
|
||||
isReplaced := deposit.IsInStateNoLock(Replaced)
|
||||
deposit.Unlock()
|
||||
if !isReplaced {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, reviveCandidate{
|
||||
deposit: deposit,
|
||||
utxo: utxo,
|
||||
})
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
confirmationHeight, err := confirmationHeightForUtxo(
|
||||
bestHeight, candidate.utxo,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deposit := candidate.deposit
|
||||
deposit.Lock()
|
||||
if !deposit.IsInStateNoLock(Replaced) {
|
||||
deposit.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
previousState := deposit.state
|
||||
previousConfirmationHeight := deposit.ConfirmationHeight
|
||||
deposit.ConfirmationHeight = confirmationHeight
|
||||
deposit.SetStateNoLock(Deposited)
|
||||
err = m.cfg.Store.UpdateDeposit(ctx, deposit)
|
||||
if err != nil {
|
||||
deposit.ConfirmationHeight = previousConfirmationHeight
|
||||
deposit.SetStateNoLock(previousState)
|
||||
deposit.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
deposit.Unlock()
|
||||
|
||||
log.Infof("Reactivated deposit %v after it reappeared in "+
|
||||
"wallet view", deposit.OutPoint)
|
||||
|
||||
err = m.startDepositFsm(ctx, deposit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateVanishedDeposits marks Deposited outputs as replaced once lnd no
|
||||
// longer reports the outpoint in multiple consecutive wallet observations.
|
||||
//
|
||||
// This closes the gap between wallet state and our DB state when a persisted
|
||||
// deposit later disappears from the wallet view, for example because an
|
||||
// unconfirmed funding transaction was replaced or because a previously
|
||||
// confirmed transaction was evicted by a deep reorg. We only invalidate
|
||||
// deposits that are still in the plain Deposited state.
|
||||
//
|
||||
// That keeps the scope narrow: in-flight states like LoopingIn already have
|
||||
// their own recovery/error handling.
|
||||
func (m *Manager) invalidateVanishedDeposits(ctx context.Context,
|
||||
utxos []*lnwallet.Utxo) error {
|
||||
|
||||
currentUtxos := make(map[wire.OutPoint]struct{}, len(utxos))
|
||||
for _, utxo := range utxos {
|
||||
currentUtxos[utxo.OutPoint] = struct{}{}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
candidates := make([]*Deposit, 0, len(m.deposits))
|
||||
for outpoint, deposit := range m.deposits {
|
||||
if _, ok := currentUtxos[outpoint]; ok {
|
||||
delete(m.missingDeposits, outpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
deposit.Lock()
|
||||
isVanishedDeposit := deposit.IsInStateNoLock(Deposited)
|
||||
deposit.Unlock()
|
||||
if !isVanishedDeposit {
|
||||
delete(m.missingDeposits, outpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
m.missingDeposits[outpoint]++
|
||||
if m.missingDeposits[outpoint] < vanishedDepositThreshold {
|
||||
log.Debugf("Waiting for another wallet observation before "+
|
||||
"marking deposit %v replaced", outpoint)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
delete(m.missingDeposits, outpoint)
|
||||
candidates = append(candidates, deposit)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, deposit := range candidates {
|
||||
deposit.Lock()
|
||||
if !deposit.IsInStateNoLock(Deposited) {
|
||||
deposit.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Persist the replacement marker before removing the deposit from the
|
||||
// active set so restarted clients and RPC consumers see the same outcome.
|
||||
previousState := deposit.state
|
||||
deposit.SetStateNoLock(Replaced)
|
||||
err := m.cfg.Store.UpdateDeposit(ctx, deposit)
|
||||
if err != nil {
|
||||
deposit.SetStateNoLock(previousState)
|
||||
deposit.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
deposit.Unlock()
|
||||
|
||||
m.removeActiveDeposit(deposit.OutPoint)
|
||||
|
||||
log.Infof("Marked vanished deposit %v as replaced",
|
||||
deposit.OutPoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterNewDeposits filters the given utxos for new deposits that we haven't
|
||||
|
|
@ -537,6 +894,20 @@ func unlockDeposits(deposits []*Deposit) {
|
|||
}
|
||||
}
|
||||
|
||||
// removeActiveDeposit removes and stops the FSM for an active outpoint.
|
||||
func (m *Manager) removeActiveDeposit(outpoint wire.OutPoint) {
|
||||
m.mu.Lock()
|
||||
fsm, ok := m.activeDeposits[outpoint]
|
||||
if ok {
|
||||
delete(m.activeDeposits, outpoint)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
fsm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllDeposits returns all active deposits.
|
||||
func (m *Manager) GetAllDeposits(ctx context.Context) ([]*Deposit, error) {
|
||||
return m.cfg.Store.AllDeposits(ctx)
|
||||
|
|
|
|||
40
staticaddr/deposit/manager_height_test.go
Normal file
40
staticaddr/deposit/manager_height_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConfirmationHeightForUtxo verifies first-confirmation height lookup for
|
||||
// wallet UTXOs.
|
||||
func TestConfirmationHeightForUtxo(t *testing.T) {
|
||||
t.Run("unconfirmed", func(t *testing.T) {
|
||||
height, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{})
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, height)
|
||||
})
|
||||
|
||||
t.Run("confirmed uses best height arithmetic", func(t *testing.T) {
|
||||
height, err := confirmationHeightForUtxo(101, &lnwallet.Utxo{
|
||||
Confirmations: 1,
|
||||
OutPoint: wire.OutPoint{
|
||||
Index: 1,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 101, height)
|
||||
})
|
||||
|
||||
t.Run("rejects impossible height", func(t *testing.T) {
|
||||
_, err := confirmationHeightForUtxo(2, &lnwallet.Utxo{
|
||||
Confirmations: 4,
|
||||
OutPoint: wire.OutPoint{
|
||||
Index: 3,
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid confirmation height")
|
||||
})
|
||||
}
|
||||
436
staticaddr/deposit/manager_reconcile_test.go
Normal file
436
staticaddr/deposit/manager_reconcile_test.go
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/loop/staticaddr/script"
|
||||
"github.com/lightninglabs/loop/staticaddr/version"
|
||||
"github.com/lightninglabs/loop/test"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// expectStableBestBlock configures two stable best-block lookups.
|
||||
func expectStableBestBlock(mockChainKit *MockChainKit, height int32) {
|
||||
mockChainKit.On(
|
||||
"GetBestBlock", mock.Anything,
|
||||
).Return(chainhash.Hash{}, height, nil).Twice()
|
||||
}
|
||||
|
||||
// TestReconcileDepositsSerialized verifies reconciliation is serialized across
|
||||
// concurrent callers.
|
||||
func TestReconcileDepositsSerialized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
utxo := &lnwallet.Utxo{
|
||||
AddressType: lnwallet.TaprootPubkey,
|
||||
Value: btcutil.Amount(100_000),
|
||||
Confirmations: 0,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{1},
|
||||
Index: 1,
|
||||
},
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var createCalls atomic.Int32
|
||||
createEntered := make(chan struct{})
|
||||
releaseCreate := make(chan struct{})
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(mock.Arguments) {
|
||||
if createCalls.Add(1) == 1 {
|
||||
close(createEntered)
|
||||
}
|
||||
|
||||
<-releaseCreate
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- manager.reconcileDeposits(ctx)
|
||||
}()
|
||||
|
||||
<-createEntered
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- manager.reconcileDeposits(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(releaseCreate)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
var gotErrs []error
|
||||
for err := range errs {
|
||||
gotErrs = append(gotErrs, err)
|
||||
}
|
||||
|
||||
require.EqualValues(t, 1, createCalls.Load())
|
||||
require.Len(t, manager.deposits, 1)
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
require.Len(t, gotErrs, 2)
|
||||
|
||||
var errCount int
|
||||
for _, err := range gotErrs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
errCount++
|
||||
require.ErrorContains(t, err, "unable to start new deposit FSM")
|
||||
}
|
||||
require.Equal(t, 1, errCount)
|
||||
}
|
||||
|
||||
// TestReconcileConfirmedDepositUsesBestBlockHeight verifies confirmation
|
||||
// heights are derived from a stable chain tip.
|
||||
func TestReconcileConfirmedDepositUsesBestBlockHeight(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
utxo := &lnwallet.Utxo{
|
||||
AddressType: lnwallet.TaprootPubkey,
|
||||
Value: btcutil.Amount(100_000),
|
||||
Confirmations: 3,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{8},
|
||||
Index: 1,
|
||||
},
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return((*script.Parameters)(nil), errors.New("fsm init failed"))
|
||||
|
||||
mockChainKit := new(MockChainKit)
|
||||
expectStableBestBlock(mockChainKit, 100)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
createdDeposit := args.Get(1).(*Deposit)
|
||||
require.EqualValues(t, 98, createdDeposit.ConfirmationHeight)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
ChainKit: mockChainKit,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
|
||||
err := manager.reconcileDeposits(ctx)
|
||||
require.ErrorContains(t, err, "unable to start new deposit FSM")
|
||||
}
|
||||
|
||||
// TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit verifies that a
|
||||
// single missing ListUnspent observation is reversible, but repeated misses
|
||||
// still mark the deposit as replaced.
|
||||
func TestReconcileDepositsInvalidatesVanishedUnconfirmedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{2},
|
||||
Index: 7,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{}, nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var updateCalls atomic.Int32
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updateCalls.Add(1)
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
require.True(t, updatedDeposit.IsInStateNoLock(Replaced))
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
fsm := &FSM{
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
<-fsm.stopChan
|
||||
close(fsm.quitChan)
|
||||
}()
|
||||
manager.activeDeposits[outpoint] = fsm
|
||||
|
||||
// The first miss only increments the consecutive-miss counter.
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.EqualValues(t, 0, updateCalls.Load())
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Len(t, manager.activeDeposits, 1)
|
||||
|
||||
// The second consecutive miss is strong enough evidence to finalize the
|
||||
// record as replaced.
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.EqualValues(t, 1, updateCalls.Load())
|
||||
require.Equal(t, Replaced, deposit.GetState())
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
select {
|
||||
case <-fsm.quitChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fsm did not stop after deposit was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileDepositsInvalidatesVanishedConfirmedDeposit verifies that a
|
||||
// previously confirmed deposit is also marked replaced if it vanishes from the
|
||||
// wallet view for multiple consecutive observations.
|
||||
func TestReconcileDepositsInvalidatesVanishedConfirmedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{9},
|
||||
Index: 4,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
ConfirmationHeight: 123,
|
||||
}
|
||||
deposit.SetState(Deposited)
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{}, nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var updateCalls atomic.Int32
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updateCalls.Add(1)
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
require.True(t, updatedDeposit.IsInStateNoLock(Replaced))
|
||||
require.EqualValues(
|
||||
t, 123, updatedDeposit.ConfirmationHeight,
|
||||
)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
fsm := &FSM{
|
||||
stopChan: make(chan struct{}),
|
||||
quitChan: make(chan struct{}),
|
||||
}
|
||||
go func() {
|
||||
<-fsm.stopChan
|
||||
close(fsm.quitChan)
|
||||
}()
|
||||
manager.activeDeposits[outpoint] = fsm
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.EqualValues(t, 0, updateCalls.Load())
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Len(t, manager.activeDeposits, 1)
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.EqualValues(t, 1, updateCalls.Load())
|
||||
require.Equal(t, Replaced, deposit.GetState())
|
||||
require.Empty(t, manager.activeDeposits)
|
||||
select {
|
||||
case <-fsm.quitChan:
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fsm did not stop after confirmed deposit was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileDepositsReactivatesReappearedReplacedDeposit verifies that the
|
||||
// same outpoint can be revived if lnd reports it again after being marked
|
||||
// replaced.
|
||||
func TestReconcileDepositsReactivatesReappearedReplacedDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{3},
|
||||
Index: 5,
|
||||
}
|
||||
|
||||
deposit := &Deposit{
|
||||
OutPoint: outpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
ConfirmationHeight: 77,
|
||||
}
|
||||
deposit.SetState(Replaced)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: outpoint,
|
||||
Value: deposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return(&script.Parameters{
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddress", mock.Anything,
|
||||
).Return((*script.StaticAddress)(nil), nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
mockStore.On(
|
||||
"UpdateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
updatedDeposit := args.Get(1).(*Deposit)
|
||||
require.True(t, updatedDeposit.IsInStateNoLock(Deposited))
|
||||
require.Zero(t, updatedDeposit.ConfirmationHeight)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
})
|
||||
manager.deposits[outpoint] = deposit
|
||||
|
||||
// Reconciliation should revive the existing record instead of creating a
|
||||
// second deposit entry for the same outpoint.
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
require.Equal(t, Deposited, deposit.GetState())
|
||||
require.Zero(t, deposit.ConfirmationHeight)
|
||||
require.Len(t, manager.activeDeposits, 1)
|
||||
}
|
||||
|
||||
// TestReconcileReplacementDepositCreatesNewDeposit ensures that a replacement
|
||||
// UTXO is retained as a new deposit while an in-flight deposit remains tied to
|
||||
// the outpoint selected by a loop-in.
|
||||
func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mockLnd := test.NewMockLnd()
|
||||
oldOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{4},
|
||||
Index: 8,
|
||||
}
|
||||
newOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{5},
|
||||
Index: 9,
|
||||
}
|
||||
|
||||
depositID, err := GetRandomDepositID()
|
||||
require.NoError(t, err)
|
||||
|
||||
deposit := &Deposit{
|
||||
ID: depositID,
|
||||
OutPoint: oldOutpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
}
|
||||
deposit.SetState(LoopingIn)
|
||||
|
||||
utxo := &lnwallet.Utxo{
|
||||
OutPoint: newOutpoint,
|
||||
Value: deposit.Value,
|
||||
Confirmations: 0,
|
||||
}
|
||||
|
||||
mockAddressManager := new(mockAddressManager)
|
||||
mockAddressManager.On(
|
||||
"ListUnspent", mock.Anything, int32(0), int32(MaxConfs),
|
||||
).Return([]*lnwallet.Utxo{utxo}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddressParameters", mock.Anything,
|
||||
).Return(&script.Parameters{
|
||||
ProtocolVersion: version.ProtocolVersion_V0,
|
||||
}, nil)
|
||||
mockAddressManager.On(
|
||||
"GetStaticAddress", mock.Anything,
|
||||
).Return((*script.StaticAddress)(nil), nil)
|
||||
|
||||
mockStore := new(mockStore)
|
||||
var createdDeposit *Deposit
|
||||
mockStore.On(
|
||||
"CreateDeposit", mock.Anything, mock.Anything,
|
||||
).Return(nil).Run(func(args mock.Arguments) {
|
||||
createdDeposit = args.Get(1).(*Deposit)
|
||||
})
|
||||
|
||||
manager := NewManager(&ManagerConfig{
|
||||
AddressManager: mockAddressManager,
|
||||
Store: mockStore,
|
||||
WalletKit: mockLnd.WalletKit,
|
||||
Signer: mockLnd.Signer,
|
||||
})
|
||||
manager.deposits[oldOutpoint] = deposit
|
||||
fsm := &FSM{}
|
||||
manager.activeDeposits[oldOutpoint] = fsm
|
||||
manager.missingDeposits[oldOutpoint] = 1
|
||||
|
||||
require.NoError(t, manager.reconcileDeposits(ctx))
|
||||
|
||||
require.Same(t, deposit, manager.deposits[oldOutpoint])
|
||||
require.Equal(t, oldOutpoint, deposit.OutPoint)
|
||||
require.Equal(t, LoopingIn, deposit.GetState())
|
||||
|
||||
replacement, ok := manager.deposits[newOutpoint]
|
||||
require.True(t, ok)
|
||||
require.Same(t, createdDeposit, replacement)
|
||||
require.NotEqual(t, depositID, replacement.ID)
|
||||
require.Equal(t, newOutpoint, replacement.OutPoint)
|
||||
require.Equal(t, Deposited, replacement.GetState())
|
||||
require.Zero(t, replacement.ConfirmationHeight)
|
||||
|
||||
require.Same(t, fsm, manager.activeDeposits[oldOutpoint])
|
||||
require.NotSame(t, fsm, manager.activeDeposits[newOutpoint])
|
||||
require.Empty(t, manager.missingDeposits)
|
||||
|
||||
mockStore.AssertNotCalled(
|
||||
t, "UpdateDeposit", mock.Anything, mock.Anything,
|
||||
)
|
||||
}
|
||||
|
|
@ -215,6 +215,49 @@ func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context,
|
|||
args.Get(1).(chan error), args.Error(2)
|
||||
}
|
||||
|
||||
type MockChainKit struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// RawClientWithMacAuth implements lndclient.ChainKitClient for tests.
|
||||
func (m *MockChainKit) RawClientWithMacAuth(
|
||||
ctx context.Context) (context.Context, time.Duration,
|
||||
chainrpc.ChainKitClient) {
|
||||
|
||||
return ctx, 0, nil
|
||||
}
|
||||
|
||||
// GetBlock implements lndclient.ChainKitClient for tests.
|
||||
func (m *MockChainKit) GetBlock(context.Context, chainhash.Hash) (
|
||||
*wire.MsgBlock, error) {
|
||||
|
||||
panic("unexpected GetBlock call")
|
||||
}
|
||||
|
||||
// GetBlockHeader implements lndclient.ChainKitClient for tests.
|
||||
func (m *MockChainKit) GetBlockHeader(context.Context, chainhash.Hash) (
|
||||
*wire.BlockHeader, error) {
|
||||
|
||||
panic("unexpected GetBlockHeader call")
|
||||
}
|
||||
|
||||
// GetBestBlock returns the configured best-block mock response.
|
||||
func (m *MockChainKit) GetBestBlock(ctx context.Context) (
|
||||
chainhash.Hash, int32, error) {
|
||||
|
||||
args := m.Called(ctx)
|
||||
|
||||
return args.Get(0).(chainhash.Hash), args.Get(1).(int32),
|
||||
args.Error(2)
|
||||
}
|
||||
|
||||
// GetBlockHash implements lndclient.ChainKitClient for tests.
|
||||
func (m *MockChainKit) GetBlockHash(context.Context, int64) (
|
||||
chainhash.Hash, error) {
|
||||
|
||||
panic("unexpected GetBlockHash call")
|
||||
}
|
||||
|
||||
// TestManager checks that the manager processes the right channel notifications
|
||||
// while a deposit is expiring.
|
||||
func TestManager(t *testing.T) {
|
||||
|
|
@ -233,6 +276,10 @@ func TestManager(t *testing.T) {
|
|||
runErrChan <- testContext.manager.Run(ctx, initChan)
|
||||
}()
|
||||
|
||||
// Send an initial block so the manager can proceed past its startup
|
||||
// block wait.
|
||||
testContext.blockChan <- int32(defaultDepositConfirmations)
|
||||
|
||||
// Ensure that the manager has been initialized.
|
||||
select {
|
||||
case <-initChan:
|
||||
|
|
@ -303,6 +350,61 @@ func TestManager(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial
|
||||
// block epoch consumed during startup is delivered to recovered deposit FSMs.
|
||||
func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
testContext := newManagerTestContext(t)
|
||||
|
||||
initChan := make(chan struct{})
|
||||
runErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
runErrChan <- testContext.manager.Run(ctx, initChan)
|
||||
}()
|
||||
|
||||
// Send only the startup block at the recovered deposit's expiry height.
|
||||
testContext.blockChan <- int32(
|
||||
defaultDepositConfirmations + defaultExpiry,
|
||||
)
|
||||
|
||||
select {
|
||||
case <-initChan:
|
||||
|
||||
case err := <-runErrChan:
|
||||
require.NoError(t, err, "manager failed to start")
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("manager timed out starting")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testContext.mockLnd.SignOutputRawChannel:
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("did not receive sign request")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testContext.mockLnd.TxPublishChannel:
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("did not receive published expiry tx")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-runErrChan:
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
|
||||
case <-time.After(defaultTimeout):
|
||||
t.Fatal("manager did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// ManagerTestContext is a helper struct that contains all the necessary
|
||||
// components to test the reservation manager.
|
||||
type ManagerTestContext struct {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcwallet/chain"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
"github.com/lightninglabs/loop"
|
||||
|
|
@ -36,6 +38,8 @@ const (
|
|||
defaultConfTarget = 3
|
||||
|
||||
DefaultPaymentTimeoutSeconds = 60
|
||||
|
||||
defaultInvoiceCleanupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -57,6 +61,24 @@ var (
|
|||
func (f *FSM) InitHtlcAction(ctx context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
var event fsm.EventType
|
||||
invoiceNeedsCleanup := false
|
||||
defer func() {
|
||||
// If we created the private invoice but failed before persisting the
|
||||
// swap, cancel it so retries do not accumulate orphan invoices.
|
||||
if !invoiceNeedsCleanup || event != fsm.OnError {
|
||||
return
|
||||
}
|
||||
|
||||
f.cancelSwapInvoice(ctx)
|
||||
}()
|
||||
|
||||
returnError := func(err error) fsm.EventType {
|
||||
event = f.HandleError(err)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// Lock the deposits and transition them to the LoopingIn state.
|
||||
err := f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits, deposit.OnLoopInInitiated,
|
||||
|
|
@ -65,7 +87,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
if err != nil {
|
||||
err = fmt.Errorf("unable to loop-in deposits: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
|
||||
// Calculate the swap invoice amount. The server needs to pay us the
|
||||
|
|
@ -88,7 +110,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to create random swap preimage: %w",
|
||||
err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.loopIn.SwapPreimage = swapPreimage
|
||||
f.loopIn.SwapHash = swapPreimage.Hash()
|
||||
|
|
@ -100,7 +122,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
if err != nil {
|
||||
err = fmt.Errorf("unable to derive client htlc key: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.loopIn.ClientPubkey = keyDesc.PubKey
|
||||
f.loopIn.HtlcKeyLocator = keyDesc.KeyLocator
|
||||
|
|
@ -119,10 +141,14 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
if err != nil {
|
||||
err = fmt.Errorf("unable to create swap invoice: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.loopIn.SwapInvoice = swapInvoice
|
||||
|
||||
// From here until CreateLoopIn succeeds, any error path would otherwise
|
||||
// leave behind a live invoice with no persisted swap to recover it.
|
||||
invoiceNeedsCleanup = true
|
||||
|
||||
f.loopIn.ProtocolVersion = version.AddressProtocolVersion(
|
||||
version.CurrentRPCProtocolVersion(),
|
||||
)
|
||||
|
|
@ -149,7 +175,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to initiate the loop-in with the "+
|
||||
"server: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
|
||||
// Pushing empty sigs signals the server that we abandoned the swap
|
||||
|
|
@ -171,7 +197,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
pushEmptySigs()
|
||||
err = fmt.Errorf("unable to parse server pubkey: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.loopIn.ServerPubkey = serverPubkey
|
||||
|
||||
|
|
@ -185,7 +211,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
err = fmt.Errorf("server response parameters are outside "+
|
||||
"our allowed range: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
|
||||
f.loopIn.HtlcCltvExpiry = loopInResp.HtlcExpiry
|
||||
|
|
@ -194,7 +220,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
pushEmptySigs()
|
||||
err = fmt.Errorf("unable to convert server nonces: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.htlcServerNoncesHighFee, err = toNonces(
|
||||
loopInResp.HighFeeHtlcInfo.Nonces,
|
||||
|
|
@ -202,7 +228,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
if err != nil {
|
||||
pushEmptySigs()
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.htlcServerNoncesExtremelyHighFee, err = toNonces(
|
||||
loopInResp.ExtremeFeeHtlcInfo.Nonces,
|
||||
|
|
@ -210,7 +236,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
if err != nil {
|
||||
pushEmptySigs()
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
|
||||
// We need to defend against the server setting high fees for the htlc
|
||||
|
|
@ -232,7 +258,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
log.Errorf("server htlc tx fee is higher than the configured "+
|
||||
"allowed maximum: %v > %v", fee, maxHtlcTxFee)
|
||||
|
||||
return f.HandleError(ErrFeeTooHigh)
|
||||
return returnError(ErrFeeTooHigh)
|
||||
}
|
||||
f.loopIn.HtlcTxFeeRate = feeRate
|
||||
|
||||
|
|
@ -246,7 +272,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
"configured allowed maximum: %v > %v", fee,
|
||||
maxHtlcTxBackupFee)
|
||||
|
||||
return f.HandleError(ErrFeeTooHigh)
|
||||
return returnError(ErrFeeTooHigh)
|
||||
}
|
||||
f.loopIn.HtlcTxHighFeeRate = highFeeRate
|
||||
|
||||
|
|
@ -262,7 +288,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
"configured allowed maximum: %v > %v", fee,
|
||||
maxHtlcTxBackupFee)
|
||||
|
||||
return f.HandleError(ErrFeeTooHigh)
|
||||
return returnError(ErrFeeTooHigh)
|
||||
}
|
||||
f.loopIn.HtlcTxExtremelyHighFeeRate = extremelyHighFeeRate
|
||||
|
||||
|
|
@ -276,7 +302,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
err = fmt.Errorf("unable to derive htlc timeout sweep "+
|
||||
"address: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
f.loopIn.HtlcTimeoutSweepAddress = sweepAddress
|
||||
|
||||
|
|
@ -286,10 +312,148 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
pushEmptySigs()
|
||||
err = fmt.Errorf("unable to store loop-in in db: %w", err)
|
||||
|
||||
return f.HandleError(err)
|
||||
return returnError(err)
|
||||
}
|
||||
|
||||
return OnHtlcInitiated
|
||||
// Once the swap is stored, restart/recovery code owns invoice lifecycle.
|
||||
invoiceNeedsCleanup = false
|
||||
|
||||
event = OnHtlcInitiated
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// cancelSwapInvoice best-effort cancels the current swap invoice using a
|
||||
// detached timeout-limited context so cleanup still runs even if the caller's
|
||||
// context is already done.
|
||||
func (f *FSM) cancelSwapInvoice(ctx context.Context) {
|
||||
cleanupCtx, cancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), defaultInvoiceCleanupTimeout,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
err := f.cfg.InvoicesClient.CancelInvoice(cleanupCtx, f.loopIn.SwapHash)
|
||||
if err != nil {
|
||||
f.Warnf("unable to cancel invoice for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInvoiceUpdate applies the monitor state's invoice-update semantics and
|
||||
// reports whether the update produced a terminal event.
|
||||
func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) (
|
||||
fsm.EventType, bool) {
|
||||
|
||||
switch update.State {
|
||||
case invoices.ContractOpen:
|
||||
return fsm.NoOp, false
|
||||
|
||||
case invoices.ContractAccepted:
|
||||
return fsm.NoOp, false
|
||||
|
||||
case invoices.ContractSettled:
|
||||
f.Debugf("received off-chain payment update %v", update.State)
|
||||
return OnPaymentReceived, true
|
||||
|
||||
case invoices.ContractCanceled:
|
||||
// If the invoice was canceled we only log here since we still need
|
||||
// to monitor until the htlc timed out.
|
||||
log.Warnf("invoice for swap hash %v canceled", f.loopIn.SwapHash)
|
||||
return fsm.NoOp, false
|
||||
|
||||
default:
|
||||
err := fmt.Errorf("unexpected invoice state %v for swap hash %v "+
|
||||
"canceled", update.State, f.loopIn.SwapHash)
|
||||
return f.HandleError(err), true
|
||||
}
|
||||
}
|
||||
|
||||
// selectedDepositConfirmationHeights returns current confirmation heights for
|
||||
// the original deposit outpoints selected by this loop-in.
|
||||
func selectedDepositConfirmationHeights(
|
||||
loopIn *StaticAddressLoopIn) map[string]int64 {
|
||||
|
||||
confirmations := make(map[string]int64, len(loopIn.Deposits))
|
||||
outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints))
|
||||
for _, outpoint := range loopIn.DepositOutpoints {
|
||||
outpoints[outpoint] = struct{}{}
|
||||
}
|
||||
|
||||
for _, d := range loopIn.Deposits {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
d.Lock()
|
||||
outpoint := d.OutPoint.String()
|
||||
confirmationHeight := d.ConfirmationHeight
|
||||
d.Unlock()
|
||||
|
||||
if _, ok := outpoints[outpoint]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
confirmations[outpoint] = confirmationHeight
|
||||
}
|
||||
|
||||
return confirmations
|
||||
}
|
||||
|
||||
// legacyMinConfsReached returns true once every original deposit is confirmed
|
||||
// and the youngest original deposit has reached the legacy confirmation target.
|
||||
func legacyMinConfsReached(outpoints []string,
|
||||
confirmationHeights map[string]int64, currentHeight int32) bool {
|
||||
|
||||
if currentHeight <= 0 || len(outpoints) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
youngestConfirmation := int64(0)
|
||||
for _, outpoint := range outpoints {
|
||||
confirmationHeight, ok := confirmationHeights[outpoint]
|
||||
if !ok || confirmationHeight <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if confirmationHeight > youngestConfirmation {
|
||||
youngestConfirmation = confirmationHeight
|
||||
}
|
||||
}
|
||||
|
||||
return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1
|
||||
}
|
||||
|
||||
// originalDepositOutpointUnavailable checks the original selected deposit
|
||||
// outpoints against the chain backend's UTXO view.
|
||||
func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) (
|
||||
bool, error) {
|
||||
|
||||
if f.cfg.TxOutChecker == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
const includeMempool = true
|
||||
for _, outpointStr := range f.loopIn.DepositOutpoints {
|
||||
outpoint, err := wire.NewOutPointFromString(outpointStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid deposit outpoint %q: %w",
|
||||
outpointStr, err)
|
||||
}
|
||||
|
||||
txOut, err := f.cfg.TxOutChecker.GetTxOut(
|
||||
ctx, *outpoint, includeMempool,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to get txout %v: %w",
|
||||
outpoint, err)
|
||||
}
|
||||
|
||||
if txOut == nil {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SignHtlcTxAction is called if the htlc was initialized and the server
|
||||
|
|
@ -300,6 +464,18 @@ func (f *FSM) SignHtlcTxAction(ctx context.Context,
|
|||
|
||||
var err error
|
||||
|
||||
outpointUnavailable, err := f.originalDepositOutpointUnavailable(ctx)
|
||||
if err != nil {
|
||||
return f.HandleError(err)
|
||||
}
|
||||
if outpointUnavailable {
|
||||
err = errors.New("original deposit outpoint no longer available")
|
||||
f.Warnf("%v, canceling swap invoice", err)
|
||||
f.cancelSwapInvoice(ctx)
|
||||
|
||||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
f.loopIn.AddressParams, err =
|
||||
f.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
||||
|
||||
|
|
@ -511,7 +687,28 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
var (
|
||||
riskAcceptedChan <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskAcceptedNotification
|
||||
riskRejectedChan <-chan *swapserverrpc.
|
||||
ServerStaticLoopInRiskRejectedNotification
|
||||
cancelRiskNotificationSubscriptions = func() {}
|
||||
)
|
||||
if f.cfg.NotificationManager != nil {
|
||||
notificationCtx, cancel := context.WithCancel(ctx)
|
||||
cancelRiskNotificationSubscriptions = cancel
|
||||
riskAcceptedChan = f.cfg.NotificationManager.
|
||||
SubscribeStaticLoopInRiskAccepted(
|
||||
notificationCtx, f.loopIn.SwapHash,
|
||||
)
|
||||
riskRejectedChan = f.cfg.NotificationManager.
|
||||
SubscribeStaticLoopInRiskRejected(
|
||||
notificationCtx, f.loopIn.SwapHash,
|
||||
)
|
||||
}
|
||||
defer cancelRiskNotificationSubscriptions()
|
||||
htlcConfirmed := false
|
||||
depositsUnlocked := false
|
||||
|
||||
invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash)
|
||||
if err != nil {
|
||||
|
|
@ -521,30 +718,40 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
return f.HandleError(err)
|
||||
}
|
||||
|
||||
// Create the swap payment timeout timer. If it runs out we cancel the
|
||||
// invoice, but keep monitoring the htlc confirmation.
|
||||
// If the invoice was canceled, e.g. before a restart, we don't need to
|
||||
// set a new deadline.
|
||||
var deadlineChan <-chan time.Time
|
||||
if invoice.State != invoices.ContractCanceled {
|
||||
// If the invoice is still live we set the timeout to the
|
||||
// remaining payment time. If too much time has elapsed, e.g.
|
||||
// after a restart, we set the timeout to 0 to cancel the
|
||||
// invoice and unlock the deposits immediately.
|
||||
remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds()
|
||||
// Create the swap payment timeout timer after the server confirms
|
||||
// confirmation risk was accepted. If a server does not support risk
|
||||
// notifications, fall back after the legacy deposit confirmation depth.
|
||||
var (
|
||||
deadlineChan <-chan time.Time
|
||||
deadlineTimer *time.Timer
|
||||
deadlineStarted bool
|
||||
)
|
||||
defer func() {
|
||||
if deadlineTimer != nil {
|
||||
deadlineTimer.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// If the invoice isn't cancelled yet and the payment timeout
|
||||
// elapsed, we set the timeout to 0 to cancel the invoice and
|
||||
// unlock the deposits immediately. Otherwise, we start the
|
||||
// timer with the remaining seconds to timeout.
|
||||
timeout := time.Duration(0) * time.Second
|
||||
if remainingTimeSeconds > 0 {
|
||||
timeout = time.Duration(remainingTimeSeconds) *
|
||||
time.Second
|
||||
startPaymentDeadline := func(reason string, startedAt time.Time) {
|
||||
if deadlineStarted || invoice.State == invoices.ContractCanceled {
|
||||
return
|
||||
}
|
||||
|
||||
deadlineChan = time.NewTimer(timeout).C
|
||||
} else {
|
||||
timeout := f.loopIn.PaymentTimeoutDuration()
|
||||
if !startedAt.IsZero() {
|
||||
timeout -= time.Since(startedAt)
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
}
|
||||
|
||||
f.Infof("starting payment deadline after %s", reason)
|
||||
deadlineTimer = time.NewTimer(timeout)
|
||||
deadlineChan = deadlineTimer.C
|
||||
deadlineStarted = true
|
||||
}
|
||||
|
||||
if invoice.State == invoices.ContractCanceled {
|
||||
// If the invoice was canceled previously we end our
|
||||
// subscription to invoice updates.
|
||||
cancelInvoiceSubscription()
|
||||
|
|
@ -557,11 +764,87 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
// Cancel the lndclient invoice subscription.
|
||||
cancelInvoiceSubscription()
|
||||
|
||||
err = f.cfg.InvoicesClient.CancelInvoice(ctx, f.loopIn.SwapHash)
|
||||
if err != nil {
|
||||
f.Warnf("unable to cancel invoice "+
|
||||
"for swap hash: %v", err)
|
||||
// Reuse the same helper as InitHtlcAction so timeout cleanup follows
|
||||
// the same detached-context path as early-init cleanup.
|
||||
f.cancelSwapInvoice(ctx)
|
||||
}
|
||||
|
||||
riskDecisionTime := func(decision ConfirmationRiskDecision) time.Time {
|
||||
now := time.Now()
|
||||
if f.cfg.Store == nil {
|
||||
return now
|
||||
}
|
||||
|
||||
storedLoopIn, err := f.cfg.Store.GetLoopInByHash(
|
||||
ctx, f.loopIn.SwapHash,
|
||||
)
|
||||
if err != nil {
|
||||
f.Warnf("unable to reload persisted risk decision for "+
|
||||
"swap %v: %v", f.loopIn.SwapHash, err)
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
if storedLoopIn == nil {
|
||||
return now
|
||||
}
|
||||
|
||||
hasPersistedDecision :=
|
||||
storedLoopIn.ConfirmationRiskDecision == decision &&
|
||||
!storedLoopIn.ConfirmationRiskDecisionTime.IsZero()
|
||||
|
||||
if !hasPersistedDecision {
|
||||
err = f.cfg.Store.RecordStaticAddressRiskDecision(
|
||||
ctx, f.loopIn.SwapHash, decision,
|
||||
)
|
||||
if err != nil {
|
||||
f.Warnf("unable to persist replayed risk "+
|
||||
"decision for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
storedLoopIn, err = f.cfg.Store.GetLoopInByHash(
|
||||
ctx, f.loopIn.SwapHash,
|
||||
)
|
||||
if err != nil {
|
||||
f.Warnf("unable to reload persisted risk "+
|
||||
"decision for swap %v: %v",
|
||||
f.loopIn.SwapHash, err)
|
||||
|
||||
return now
|
||||
}
|
||||
if storedLoopIn == nil ||
|
||||
storedLoopIn.ConfirmationRiskDecision != decision ||
|
||||
storedLoopIn.ConfirmationRiskDecisionTime.IsZero() {
|
||||
|
||||
return now
|
||||
}
|
||||
}
|
||||
|
||||
f.loopIn.ConfirmationRiskDecision =
|
||||
storedLoopIn.ConfirmationRiskDecision
|
||||
f.loopIn.ConfirmationRiskDecisionTime =
|
||||
storedLoopIn.ConfirmationRiskDecisionTime
|
||||
|
||||
return storedLoopIn.ConfirmationRiskDecisionTime
|
||||
}
|
||||
|
||||
switch f.loopIn.ConfirmationRiskDecision {
|
||||
case ConfirmationRiskDecisionAccepted:
|
||||
startPaymentDeadline(
|
||||
"recovered risk accepted notification",
|
||||
f.loopIn.ConfirmationRiskDecisionTime,
|
||||
)
|
||||
|
||||
case ConfirmationRiskDecisionRejected:
|
||||
cancelInvoiceSubscription()
|
||||
f.cancelSwapInvoice(ctx)
|
||||
|
||||
return f.HandleError(errors.New(
|
||||
"server rejected confirmation risk wait",
|
||||
))
|
||||
}
|
||||
|
||||
for {
|
||||
|
|
@ -603,19 +886,87 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
}
|
||||
|
||||
case <-deadlineChan:
|
||||
deadlineChan = nil
|
||||
|
||||
// If the server didn't pay the invoice on time, we
|
||||
// cancel the invoice and keep monitoring the htlc tx
|
||||
// confirmation. We also need to unlock the deposits to
|
||||
// re-enable them for loop-ins and withdrawals.
|
||||
cancelInvoice()
|
||||
|
||||
event := f.UnlockDepositsAction(ctx, nil)
|
||||
if event != fsm.OnError {
|
||||
err := f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
f.Errorf("unable to unlock deposits after " +
|
||||
"payment deadline")
|
||||
continue
|
||||
}
|
||||
depositsUnlocked = true
|
||||
|
||||
case riskAccepted, ok := <-riskAcceptedChan:
|
||||
if !ok {
|
||||
riskAcceptedChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.Equal(
|
||||
riskAccepted.SwapHash, f.loopIn.SwapHash[:],
|
||||
) {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
startedAt := riskDecisionTime(
|
||||
ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
f.loopIn.ConfirmationRiskDecision =
|
||||
ConfirmationRiskDecisionAccepted
|
||||
f.loopIn.ConfirmationRiskDecisionTime = startedAt
|
||||
startPaymentDeadline(
|
||||
"risk accepted notification",
|
||||
f.loopIn.ConfirmationRiskDecisionTime,
|
||||
)
|
||||
|
||||
case riskRejected, ok := <-riskRejectedChan:
|
||||
if !ok {
|
||||
riskRejectedChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if !bytes.Equal(
|
||||
riskRejected.SwapHash, f.loopIn.SwapHash[:],
|
||||
) {
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cancelInvoiceSubscription()
|
||||
f.cancelSwapInvoice(ctx)
|
||||
decisionTime := riskDecisionTime(
|
||||
ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
f.loopIn.ConfirmationRiskDecision =
|
||||
ConfirmationRiskDecisionRejected
|
||||
f.loopIn.ConfirmationRiskDecisionTime = decisionTime
|
||||
|
||||
return f.HandleError(errors.New(
|
||||
"server rejected confirmation risk wait",
|
||||
))
|
||||
|
||||
case currentHeight := <-blockChan:
|
||||
depositConfirmationHeights :=
|
||||
selectedDepositConfirmationHeights(f.loopIn)
|
||||
|
||||
if legacyMinConfsReached(
|
||||
f.loopIn.DepositOutpoints,
|
||||
depositConfirmationHeights, currentHeight,
|
||||
) {
|
||||
|
||||
startPaymentDeadline(
|
||||
"legacy confirmation fallback",
|
||||
time.Time{},
|
||||
)
|
||||
}
|
||||
|
||||
// If the htlc is confirmed but blockChan fires before
|
||||
// htlcConfChan, we would wrongfully assume that the
|
||||
// htlc tx was not confirmed which would lead to
|
||||
|
|
@ -641,13 +992,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
if !htlcConfirmed {
|
||||
f.Infof("swap timed out, htlc not confirmed")
|
||||
|
||||
// If the htlc hasn't confirmed but the timeout
|
||||
// path opened up, and we didn't receive the
|
||||
// swap payment, we consider the swap attempt to
|
||||
// be failed. We cancelled the invoice, but
|
||||
// don't need to unlock the deposits because
|
||||
// that happened when the payment deadline was
|
||||
// reached.
|
||||
if !depositsUnlocked {
|
||||
err = f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
return f.HandleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return OnSwapTimedOut
|
||||
}
|
||||
|
||||
|
|
@ -671,32 +1022,22 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context,
|
|||
|
||||
return f.HandleError(err)
|
||||
|
||||
case update := <-invoiceUpdateChan:
|
||||
switch update.State {
|
||||
case invoices.ContractOpen:
|
||||
case invoices.ContractAccepted:
|
||||
case invoices.ContractSettled:
|
||||
f.Debugf("received off-chain payment update "+
|
||||
"%v", update.State)
|
||||
|
||||
return OnPaymentReceived
|
||||
|
||||
case invoices.ContractCanceled:
|
||||
// If the invoice was canceled we only log here
|
||||
// since we still need to monitor until the htlc
|
||||
// timed out.
|
||||
log.Warnf("invoice for swap hash %v canceled",
|
||||
f.loopIn.SwapHash)
|
||||
|
||||
default:
|
||||
err = fmt.Errorf("unexpected invoice state %v "+
|
||||
"for swap hash %v canceled",
|
||||
update.State, f.loopIn.SwapHash)
|
||||
|
||||
return f.HandleError(err)
|
||||
case update, ok := <-invoiceUpdateChan:
|
||||
if !ok {
|
||||
invoiceUpdateChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if event, done := f.handleInvoiceUpdate(update); done {
|
||||
return event
|
||||
}
|
||||
|
||||
case err, ok := <-invoiceErrChan:
|
||||
if !ok {
|
||||
invoiceErrChan = nil
|
||||
continue
|
||||
}
|
||||
|
||||
case err = <-invoiceErrChan:
|
||||
f.Errorf("invoice subscription error: %v", err)
|
||||
|
||||
case <-ctx.Done():
|
||||
|
|
@ -824,9 +1165,7 @@ func (f *FSM) PaymentReceivedAction(ctx context.Context,
|
|||
func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
||||
_ fsm.EventContext) fsm.EventType {
|
||||
|
||||
err := f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
|
||||
)
|
||||
err := f.unlockDeposits(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("unable to unlock deposits: %w", err)
|
||||
|
||||
|
|
@ -836,6 +1175,13 @@ func (f *FSM) UnlockDepositsAction(ctx context.Context,
|
|||
return fsm.OnError
|
||||
}
|
||||
|
||||
// unlockDeposits resets this loop-in's deposits so they can be selected again.
|
||||
func (f *FSM) unlockDeposits(ctx context.Context) error {
|
||||
return f.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, f.loopIn.Deposits, fsm.OnError, deposit.Deposited,
|
||||
)
|
||||
}
|
||||
|
||||
// createAndPublishHtlcTimeoutSweepTx creates and publishes the htlc timeout
|
||||
// sweep transaction.
|
||||
func (f *FSM) createAndPublishHtlcTimeoutSweepTx(ctx context.Context) error {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/loop"
|
||||
"github.com/lightninglabs/loop/fsm"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
|
|
@ -87,6 +88,11 @@ type StaticAddressLoopInStore interface {
|
|||
// IsStored checks if the loop-in is already stored in the database.
|
||||
IsStored(ctx context.Context, swapHash lntypes.Hash) (bool, error)
|
||||
|
||||
// RecordStaticAddressRiskDecision persists the server's
|
||||
// confirmation-risk decision for the loop-in identified by swapHash.
|
||||
RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error
|
||||
|
||||
// GetLoopInByHash returns the loop-in swap with the given hash.
|
||||
GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) (
|
||||
*StaticAddressLoopIn, error)
|
||||
|
|
@ -105,10 +111,34 @@ type QuoteGetter interface {
|
|||
numDeposits uint32, fast bool) (*loop.LoopInQuote, error)
|
||||
}
|
||||
|
||||
// TxOutChecker checks whether an outpoint is still available in the chain
|
||||
// backend's UTXO view.
|
||||
type TxOutChecker interface {
|
||||
// GetTxOut returns nil if the outpoint is unavailable or spent. The
|
||||
// includeMempool flag must be passed through to the underlying chain
|
||||
// backend.
|
||||
GetTxOut(ctx context.Context, outpoint wire.OutPoint,
|
||||
includeMempool bool) (*wire.TxOut, error)
|
||||
}
|
||||
|
||||
type NotificationManager interface {
|
||||
// SubscribeStaticLoopInSweepRequests subscribes to the static loop in
|
||||
// sweep requests. These are sent by the server to the client to request
|
||||
// a sweep of a static loop in that has been finished.
|
||||
SubscribeStaticLoopInSweepRequests(ctx context.Context,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification
|
||||
|
||||
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk
|
||||
// accepted notifications. These are sent by the server after the selected
|
||||
// deposits are accepted by confirmation risk tracking.
|
||||
SubscribeStaticLoopInRiskAccepted(
|
||||
ctx context.Context, swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification
|
||||
|
||||
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk
|
||||
// rejected notifications. These are sent by the server if it aborts the
|
||||
// confirmation risk wait before payment.
|
||||
SubscribeStaticLoopInRiskRejected(
|
||||
ctx context.Context, swapHash lntypes.Hash,
|
||||
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,23 @@ import (
|
|||
"github.com/lightningnetwork/lnd/zpay32"
|
||||
)
|
||||
|
||||
// ConfirmationRiskDecision records the server's decision on whether it accepts
|
||||
// waiting for low-confirmation deposits before paying a static loop-in invoice.
|
||||
type ConfirmationRiskDecision string
|
||||
|
||||
const (
|
||||
// ConfirmationRiskDecisionNone means no risk decision has been received.
|
||||
ConfirmationRiskDecisionNone ConfirmationRiskDecision = ""
|
||||
|
||||
// ConfirmationRiskDecisionAccepted means the server accepted waiting for
|
||||
// deposit confirmations and the payment deadline has started.
|
||||
ConfirmationRiskDecisionAccepted ConfirmationRiskDecision = "accepted"
|
||||
|
||||
// ConfirmationRiskDecisionRejected means the server stopped waiting for
|
||||
// deposit confirmations before paying the invoice.
|
||||
ConfirmationRiskDecisionRejected ConfirmationRiskDecision = "rejected"
|
||||
)
|
||||
|
||||
// StaticAddressLoopIn represents the in-memory loop-in information.
|
||||
type StaticAddressLoopIn struct {
|
||||
// SwapHash is the hashed preimage of the swap invoice. It represents
|
||||
|
|
@ -93,8 +110,6 @@ type StaticAddressLoopIn struct {
|
|||
|
||||
// The outpoints in the format txid:vout that are part of the loop-in
|
||||
// swap.
|
||||
// TODO(hieblmi): Replace this with a getter method that fetches the
|
||||
// outpoints from the deposits.
|
||||
DepositOutpoints []string
|
||||
|
||||
// SelectedAmount is the amount that the user selected for the swap. If
|
||||
|
|
@ -109,6 +124,15 @@ type StaticAddressLoopIn struct {
|
|||
// LastUpdateTime is the timestamp of the latest persisted state update.
|
||||
LastUpdateTime time.Time
|
||||
|
||||
// ConfirmationRiskDecision records the server's persisted decision on
|
||||
// low-confirmation deposit risk.
|
||||
ConfirmationRiskDecision ConfirmationRiskDecision
|
||||
|
||||
// ConfirmationRiskDecisionTime is when loopd persisted the server risk
|
||||
// decision. It is used to reconstruct payment-deadline timeouts after
|
||||
// restart.
|
||||
ConfirmationRiskDecisionTime time.Time
|
||||
|
||||
// state is the current state of the swap.
|
||||
state fsm.StateType
|
||||
|
||||
|
|
@ -469,12 +493,28 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount {
|
|||
|
||||
// RemainingPaymentTimeSeconds returns the remaining time in seconds until the
|
||||
// payment timeout is reached. The remaining time is calculated from the
|
||||
// initiation time of the swap. If more than the swaps configured payment
|
||||
// initiation time of the swap. If more than the swap's configured payment
|
||||
// timeout has passed, the remaining time will be negative.
|
||||
func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 {
|
||||
elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds()
|
||||
|
||||
return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation)
|
||||
return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation)
|
||||
}
|
||||
|
||||
// PaymentTimeoutDuration returns the configured payment timeout duration,
|
||||
// falling back to the default if the swap predates the persisted timeout field.
|
||||
func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration {
|
||||
return time.Duration(l.paymentTimeoutSeconds()) * time.Second
|
||||
}
|
||||
|
||||
// paymentTimeoutSeconds returns the configured timeout in seconds.
|
||||
func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 {
|
||||
timeoutSeconds := int64(l.PaymentTimeoutSeconds)
|
||||
if timeoutSeconds == 0 {
|
||||
timeoutSeconds = int64(DefaultPaymentTimeoutSeconds)
|
||||
}
|
||||
|
||||
return timeoutSeconds
|
||||
}
|
||||
|
||||
// Outpoints returns the wire outpoints of the deposits.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
|
|
@ -78,6 +79,10 @@ type Config struct {
|
|||
// blocks.
|
||||
ChainNotifier lndclient.ChainNotifierClient
|
||||
|
||||
// TxOutChecker checks whether selected deposit outpoints are still
|
||||
// available before we sign an HTLC transaction for them.
|
||||
TxOutChecker TxOutChecker
|
||||
|
||||
// Signer is the signer client that is used to sign transactions.
|
||||
Signer lndclient.SignerClient
|
||||
|
||||
|
|
@ -754,8 +759,10 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
}
|
||||
|
||||
swap := &StaticAddressLoopIn{
|
||||
SelectedAmount: req.SelectedAmount,
|
||||
DepositOutpoints: selectedOutpoints,
|
||||
SelectedAmount: req.SelectedAmount,
|
||||
DepositOutpoints: append(
|
||||
[]string(nil), selectedOutpoints...,
|
||||
),
|
||||
Deposits: selectedDeposits,
|
||||
Label: req.Label,
|
||||
Initiator: req.Initiator,
|
||||
|
|
@ -845,11 +852,11 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn,
|
|||
return swaps, nil
|
||||
}
|
||||
|
||||
// SelectDeposits sorts the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending order. It then selects the deposits that
|
||||
// are needed to cover the amount requested without leaving a dust change. It
|
||||
// returns an error if the sum of deposits minus dust is less than the requested
|
||||
// amount.
|
||||
// SelectDeposits sorts deposits by confirmation status first, then by amount in
|
||||
// descending order, then by blocks-until-expiry in ascending order. It then
|
||||
// selects the deposits that are needed to cover the amount requested without
|
||||
// leaving a dust change. It returns an error if the sum of deposits minus dust
|
||||
// is less than the requested amount.
|
||||
func SelectDeposits(targetAmount btcutil.Amount,
|
||||
unfilteredDeposits []*deposit.Deposit, csvExpiry uint32,
|
||||
blockHeight uint32) ([]*deposit.Deposit, error) {
|
||||
|
|
@ -870,14 +877,25 @@ func SelectDeposits(targetAmount btcutil.Amount,
|
|||
deposits = append(deposits, d)
|
||||
}
|
||||
|
||||
// Sort the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending order.
|
||||
// Sort confirmed deposits ahead of unconfirmed ones so auto-selection
|
||||
// prefers deposits the server can accept immediately. Within each group
|
||||
// we prefer larger deposits, then earlier expiries.
|
||||
sort.Slice(deposits, func(i, j int) bool {
|
||||
iConfirmed := deposits[i].ConfirmationHeight > 0
|
||||
jConfirmed := deposits[j].ConfirmationHeight > 0
|
||||
if iConfirmed != jConfirmed {
|
||||
return iConfirmed
|
||||
}
|
||||
|
||||
if deposits[i].Value == deposits[j].Value {
|
||||
iExp := uint32(deposits[i].ConfirmationHeight) +
|
||||
csvExpiry - blockHeight
|
||||
jExp := uint32(deposits[j].ConfirmationHeight) +
|
||||
csvExpiry - blockHeight
|
||||
iExp := blocksUntilDepositExpiry(
|
||||
uint32(deposits[i].ConfirmationHeight),
|
||||
blockHeight, csvExpiry,
|
||||
)
|
||||
jExp := blocksUntilDepositExpiry(
|
||||
uint32(deposits[j].ConfirmationHeight),
|
||||
blockHeight, csvExpiry,
|
||||
)
|
||||
|
||||
return iExp < jExp
|
||||
}
|
||||
|
|
@ -909,20 +927,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},
|
||||
|
|
@ -298,6 +319,12 @@ func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) {
|
|||
require.ErrorContains(t, err, depOutpoint)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -433,6 +460,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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ var (
|
|||
// ErrInvalidOutpoint is returned when an outpoint contains the outpoint
|
||||
// separator.
|
||||
ErrInvalidOutpoint = errors.New("outpoint contains outpoint separator")
|
||||
|
||||
// ErrLoopInNotFound is returned when a loop-in swap is not stored.
|
||||
ErrLoopInNotFound = errors.New("static address loop-in not found")
|
||||
)
|
||||
|
||||
// Querier is the interface that contains all the queries generated by sqlc for
|
||||
|
|
@ -51,6 +54,11 @@ type Querier interface {
|
|||
UpdateStaticAddressLoopIn(ctx context.Context,
|
||||
arg sqlc.UpdateStaticAddressLoopInParams) error
|
||||
|
||||
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
|
||||
// decision for a loop-in swap.
|
||||
RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
arg sqlc.RecordStaticAddressRiskDecisionParams) error
|
||||
|
||||
// GetStaticAddressLoopInSwap retrieves a loop-in swap by its swap hash.
|
||||
GetStaticAddressLoopInSwap(ctx context.Context,
|
||||
swapHash []byte) (sqlc.GetStaticAddressLoopInSwapRow, error)
|
||||
|
|
@ -203,7 +211,7 @@ func (s *SqlStore) GetStaticAddressLoopInSwapsByStates(ctx context.Context,
|
|||
}
|
||||
|
||||
func toJointStringStates(states []fsm.StateType) string {
|
||||
return "{" + strings.Join(toStrings(states), ",") + "}"
|
||||
return strings.Join(toStrings(states), ",")
|
||||
}
|
||||
|
||||
func toStrings(states []fsm.StateType) []string {
|
||||
|
|
@ -361,6 +369,43 @@ func (s *SqlStore) UpdateLoopIn(ctx context.Context,
|
|||
)
|
||||
}
|
||||
|
||||
// RecordStaticAddressRiskDecision stores the server's confirmation-risk
|
||||
// decision for a static address loop-in. The timestamp is written by the store
|
||||
// so recovery can reconstruct the remaining payment deadline from one durable
|
||||
// clock source.
|
||||
func (s *SqlStore) RecordStaticAddressRiskDecision(ctx context.Context,
|
||||
swapHash lntypes.Hash, decision ConfirmationRiskDecision) error {
|
||||
|
||||
if decision != ConfirmationRiskDecisionAccepted &&
|
||||
decision != ConfirmationRiskDecisionRejected {
|
||||
|
||||
return errors.New("unknown confirmation risk decision")
|
||||
}
|
||||
|
||||
params := sqlc.RecordStaticAddressRiskDecisionParams{
|
||||
SwapHash: swapHash[:],
|
||||
ConfirmationRiskDecision: string(decision),
|
||||
ConfirmationRiskDecisionTime: sql.NullTime{
|
||||
Time: s.clock.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
|
||||
return s.baseDB.ExecTx(ctx, loopdb.NewSqlWriteOpts(),
|
||||
func(q Querier) error {
|
||||
stored, err := q.IsStored(ctx, swapHash[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stored {
|
||||
return ErrLoopInNotFound
|
||||
}
|
||||
|
||||
return q.RecordStaticAddressRiskDecision(ctx, params)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *SqlStore) BatchUpdateSelectedSwapAmounts(ctx context.Context,
|
||||
updateAmounts map[lntypes.Hash]btcutil.Amount) error {
|
||||
|
||||
|
|
@ -507,9 +552,12 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
|
|||
}
|
||||
}
|
||||
|
||||
depositOutpoints := strings.Split(
|
||||
swap.DepositOutpoints, OutpointSeparator,
|
||||
)
|
||||
var depositOutpoints []string
|
||||
if swap.DepositOutpoints != "" {
|
||||
depositOutpoints = strings.Split(
|
||||
swap.DepositOutpoints, OutpointSeparator,
|
||||
)
|
||||
}
|
||||
|
||||
timeoutAddressString := swap.HtlcTimeoutSweepAddress
|
||||
var timeoutAddress btcutil.Address
|
||||
|
|
@ -580,6 +628,9 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
|
|||
DepositOutpoints: depositOutpoints,
|
||||
SelectedAmount: btcutil.Amount(swap.SelectedAmount),
|
||||
Fast: swap.Fast,
|
||||
ConfirmationRiskDecision: ConfirmationRiskDecision(
|
||||
swap.ConfirmationRiskDecision,
|
||||
),
|
||||
HtlcTxFeeRate: chainfee.SatPerKWeight(
|
||||
swap.HtlcTxFeeRateSatKw,
|
||||
),
|
||||
|
|
@ -587,6 +638,10 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
|
|||
HtlcTimeoutSweepTxHash: htlcTimeoutSweepTxHash,
|
||||
Deposits: depositList,
|
||||
}
|
||||
if swap.ConfirmationRiskDecisionTime.Valid {
|
||||
loopIn.ConfirmationRiskDecisionTime =
|
||||
swap.ConfirmationRiskDecisionTime.Time
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
lastUpdate := updates[len(updates)-1]
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
|
|||
|
||||
loopingDepositID := newID()
|
||||
loopedInDepositID := newID()
|
||||
d1, d2 := &deposit.Deposit{
|
||||
failedDepositID := newID()
|
||||
d1, d2, d3 := &deposit.Deposit{
|
||||
ID: loopingDepositID,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
|
||||
|
|
@ -63,29 +64,48 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
|
|||
TimeOutSweepPkScript: []byte{
|
||||
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4d,
|
||||
},
|
||||
},
|
||||
&deposit.Deposit{
|
||||
ID: failedDepositID,
|
||||
OutPoint: wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x3a, 0x2b, 0x3c, 0x4e},
|
||||
Index: 2,
|
||||
},
|
||||
Value: btcutil.Amount(300_000),
|
||||
TimeOutSweepPkScript: []byte{
|
||||
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x4f,
|
||||
},
|
||||
}
|
||||
|
||||
err := depositStore.CreateDeposit(ctxb, d1)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.CreateDeposit(ctxb, d2)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.CreateDeposit(ctxb, d3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add two updates per deposit, expect the last to be retrieved.
|
||||
d1.SetState(deposit.Deposited)
|
||||
d2.SetState(deposit.Deposited)
|
||||
d3.SetState(deposit.Deposited)
|
||||
|
||||
err = depositStore.UpdateDeposit(ctxb, d1)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.UpdateDeposit(ctxb, d2)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.UpdateDeposit(ctxb, d3)
|
||||
require.NoError(t, err)
|
||||
|
||||
d1.SetState(deposit.LoopingIn)
|
||||
d2.SetState(deposit.LoopedIn)
|
||||
d3.SetState(deposit.Deposited)
|
||||
|
||||
err = depositStore.UpdateDeposit(ctxb, d1)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.UpdateDeposit(ctxb, d2)
|
||||
require.NoError(t, err)
|
||||
err = depositStore.UpdateDeposit(ctxb, d3)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, clientPubKey := test.CreateKey(1)
|
||||
_, serverPubKey := test.CreateKey(2)
|
||||
|
|
@ -124,6 +144,23 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
|
|||
err = swapStore.CreateLoopIn(ctxb, &swapSucceeded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create failed swap. Failed is the last final state, so this
|
||||
// exercises the state-list query boundary.
|
||||
swapHashFailed := lntypes.Hash{0x3, 0x2, 0x3, 0x5}
|
||||
swapFailed := StaticAddressLoopIn{
|
||||
SwapHash: swapHashFailed,
|
||||
SwapPreimage: lntypes.Preimage{0x3, 0x2, 0x3, 0x5},
|
||||
DepositOutpoints: []string{d3.OutPoint.String()},
|
||||
Deposits: []*deposit.Deposit{d3},
|
||||
ClientPubkey: clientPubKey,
|
||||
ServerPubkey: serverPubKey,
|
||||
HtlcTimeoutSweepAddress: addr,
|
||||
}
|
||||
swapFailed.SetState(Failed)
|
||||
|
||||
err = swapStore.CreateLoopIn(ctxb, &swapFailed)
|
||||
require.NoError(t, err)
|
||||
|
||||
pendingSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, PendingStates)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -142,10 +179,12 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
|
|||
finalizedSwaps, err := swapStore.GetStaticAddressLoopInSwapsByStates(ctxb, FinalStates)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, finalizedSwaps, 1)
|
||||
require.Len(t, finalizedSwaps, 2)
|
||||
require.Equal(t, swapHashSucceeded, finalizedSwaps[0].SwapHash)
|
||||
require.Equal(t, []string{d2.OutPoint.String()}, finalizedSwaps[0].DepositOutpoints)
|
||||
require.Equal(t, Succeeded, finalizedSwaps[0].GetState())
|
||||
require.Equal(t, swapHashFailed, finalizedSwaps[1].SwapHash)
|
||||
require.Equal(t, Failed, finalizedSwaps[1].GetState())
|
||||
|
||||
finalizedDeposits := finalizedSwaps[0].Deposits
|
||||
require.Len(t, finalizedDeposits, 1)
|
||||
|
|
@ -258,6 +297,31 @@ func TestCreateLoopIn(t *testing.T) {
|
|||
require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()},
|
||||
swap.DepositOutpoints)
|
||||
require.Equal(t, SignHtlcTx, swap.GetState())
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionNone,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
|
||||
decisionTime := time.Unix(123, 0).UTC()
|
||||
testClock.SetTime(decisionTime)
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, swapHashPending, ConfirmationRiskDecisionAccepted,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, ConfirmationRiskDecisionAccepted,
|
||||
swap.ConfirmationRiskDecision,
|
||||
)
|
||||
require.True(t, swap.ConfirmationRiskDecisionTime.Equal(decisionTime))
|
||||
|
||||
err = swapStore.RecordStaticAddressRiskDecision(
|
||||
ctx, lntypes.Hash{0x9, 0x9, 0x9},
|
||||
ConfirmationRiskDecisionRejected,
|
||||
)
|
||||
require.ErrorIs(t, err, ErrLoopInNotFound)
|
||||
|
||||
require.Len(t, swap.Deposits, 2)
|
||||
|
||||
|
|
@ -286,3 +350,76 @@ func TestCreateLoopIn(t *testing.T) {
|
|||
time.Microsecond,
|
||||
)
|
||||
}
|
||||
|
||||
// TestGetLoopInByHashPreservesStoredDepositOutpoints ensures recovered loop-ins
|
||||
// keep the original outpoint snapshot stored when the swap was created.
|
||||
func TestGetLoopInByHashPreservesStoredDepositOutpoints(t *testing.T) {
|
||||
ctxb := context.Background()
|
||||
testDb := loopdb.NewTestDB(t)
|
||||
testClock := clock.NewTestClock(time.Now())
|
||||
defer testDb.Close()
|
||||
|
||||
depositStore := deposit.NewSqlStore(testDb.BaseDB)
|
||||
swapStore := NewSqlStore(
|
||||
loopdb.NewTypedStore[Querier](testDb), testClock,
|
||||
&chaincfg.RegressionNetParams,
|
||||
)
|
||||
|
||||
depositID, err := deposit.GetRandomDepositID()
|
||||
require.NoError(t, err)
|
||||
|
||||
oldOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x1a, 0x2b, 0x3c, 0x4d},
|
||||
Index: 0,
|
||||
}
|
||||
currentOutpoint := wire.OutPoint{
|
||||
Hash: chainhash.Hash{0x5a, 0x6b, 0x7c, 0x8d},
|
||||
Index: 1,
|
||||
}
|
||||
|
||||
d := &deposit.Deposit{
|
||||
ID: depositID,
|
||||
OutPoint: oldOutpoint,
|
||||
Value: btcutil.Amount(100_000),
|
||||
TimeOutSweepPkScript: []byte{
|
||||
0x00, 0x14, 0x1a, 0x2b, 0x3c, 0x41,
|
||||
},
|
||||
}
|
||||
require.NoError(t, depositStore.CreateDeposit(ctxb, d))
|
||||
|
||||
d.SetState(deposit.LoopingIn)
|
||||
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
|
||||
|
||||
_, clientPubKey := test.CreateKey(1)
|
||||
_, serverPubKey := test.CreateKey(2)
|
||||
addr, err := btcutil.DecodeAddress(P2wkhAddr, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
swapHash := lntypes.Hash{0x1, 0x2, 0x3, 0x4}
|
||||
swap := StaticAddressLoopIn{
|
||||
SwapHash: swapHash,
|
||||
SwapPreimage: lntypes.Preimage{0x1, 0x2, 0x3, 0x4},
|
||||
DepositOutpoints: []string{oldOutpoint.String()},
|
||||
Deposits: []*deposit.Deposit{d},
|
||||
ClientPubkey: clientPubKey,
|
||||
ServerPubkey: serverPubKey,
|
||||
HtlcTimeoutSweepAddress: addr,
|
||||
}
|
||||
swap.SetState(SignHtlcTx)
|
||||
|
||||
require.NoError(t, swapStore.CreateLoopIn(ctxb, &swap))
|
||||
|
||||
d.OutPoint = currentOutpoint
|
||||
d.ConfirmationHeight = 42
|
||||
require.NoError(t, depositStore.UpdateDeposit(ctxb, d))
|
||||
|
||||
storedSwap, err := swapStore.GetLoopInByHash(ctxb, swapHash)
|
||||
require.NoError(t, err)
|
||||
require.Equal(
|
||||
t, []string{oldOutpoint.String()},
|
||||
storedSwap.DepositOutpoints,
|
||||
)
|
||||
require.Len(t, storedSwap.Deposits, 1)
|
||||
require.Equal(t, currentOutpoint, storedSwap.Deposits[0].OutPoint)
|
||||
require.Equal(t, int64(42), storedSwap.Deposits[0].ConfirmationHeight)
|
||||
}
|
||||
|
|
|
|||
72
staticaddr/loopin/txout_checker.go
Normal file
72
staticaddr/loopin/txout_checker.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
)
|
||||
|
||||
// lndTxOutChecker checks outpoint availability using lnd's wallet transaction
|
||||
// view. It returns nil for outputs already spent by a wallet-known transaction.
|
||||
type lndTxOutChecker struct {
|
||||
client lndclient.LightningClient
|
||||
}
|
||||
|
||||
// NewLndTxOutChecker creates a TxOutChecker backed by lnd.
|
||||
func NewLndTxOutChecker(client lndclient.LightningClient) TxOutChecker {
|
||||
return &lndTxOutChecker{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTxOut returns the tx output if lnd's transaction view still reports the
|
||||
// outpoint as unspent.
|
||||
func (c *lndTxOutChecker) GetTxOut(ctx context.Context,
|
||||
outpoint wire.OutPoint, includeMempool bool) (*wire.TxOut, error) {
|
||||
|
||||
endHeight := int32(0)
|
||||
if includeMempool {
|
||||
endHeight = -1
|
||||
}
|
||||
|
||||
// We need lnd's wallet transaction view rather than only the funding
|
||||
// transaction: a matching previous outpoint tells us the deposit has
|
||||
// already been spent by a wallet-known transaction. When mempool spends
|
||||
// matter, lnd exposes them through ListTransactions with endHeight=-1.
|
||||
txs, err := c.client.ListTransactions(ctx, 0, endHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outpointStr := outpoint.String()
|
||||
for _, tx := range txs {
|
||||
for _, prevOutpoint := range tx.PreviousOutpoints {
|
||||
if prevOutpoint.GetOutpoint() == outpointStr {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tx := range txs {
|
||||
if tx.Tx == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
txHash := tx.TxHash
|
||||
if txHash == "" {
|
||||
txHash = tx.Tx.TxHash().String()
|
||||
}
|
||||
if txHash != outpoint.Hash.String() {
|
||||
continue
|
||||
}
|
||||
|
||||
if int(outpoint.Index) >= len(tx.Tx.TxOut) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return tx.Tx.TxOut[outpoint.Index], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -310,6 +310,10 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Automatic channel funding must ignore mempool deposits because
|
||||
// they cannot yet be used as funding inputs.
|
||||
deposits = filterConfirmedDeposits(deposits)
|
||||
|
||||
if req.LocalFundingAmount != 0 {
|
||||
deposits, err = staticutil.SelectDeposits(
|
||||
deposits, req.LocalFundingAmount,
|
||||
|
|
@ -325,6 +329,14 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
// Deposited now includes mempool outputs for static loop-ins, but
|
||||
// channel opens still require the deposit input to be confirmed.
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
return nil, ErrOpeningChannelUnavailableDeposits
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-check: calculate the channel funding amount and the optional
|
||||
// change before locking deposits. This ensures the selected deposits
|
||||
// can cover the funding amount plus fees.
|
||||
|
|
@ -399,6 +411,22 @@ func (m *Manager) OpenChannel(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// filterConfirmedDeposits filters the given deposits and returns only those
|
||||
// that have a positive confirmation height, i.e. deposits that have been
|
||||
// confirmed on-chain.
|
||||
func filterConfirmedDeposits(deposits []*deposit.Deposit) []*deposit.Deposit {
|
||||
confirmed := make([]*deposit.Deposit, 0, len(deposits))
|
||||
for _, d := range deposits {
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
confirmed = append(confirmed, d)
|
||||
}
|
||||
|
||||
return confirmed
|
||||
}
|
||||
|
||||
// openChannelPsbt starts an interactive channel open protocol that uses a
|
||||
// partially signed bitcoin transaction (PSBT) to fund the channel output. The
|
||||
// protocol involves several steps between the loop client and the server:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type transitionCall struct {
|
|||
}
|
||||
|
||||
type mockDepositManager struct {
|
||||
activeDeposits []*deposit.Deposit
|
||||
openingDeposits []*deposit.Deposit
|
||||
getErr error
|
||||
transitionErrs map[fsm.EventType]error
|
||||
|
|
@ -44,15 +45,19 @@ func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
|
|||
func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
if stateFilter != deposit.OpeningChannel {
|
||||
return nil, nil
|
||||
switch stateFilter {
|
||||
case deposit.Deposited:
|
||||
return m.activeDeposits, nil
|
||||
|
||||
case deposit.OpeningChannel:
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
|
||||
return m.openingDeposits, nil
|
||||
}
|
||||
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
|
||||
return m.openingDeposits, nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
|
||||
|
|
@ -464,6 +469,97 @@ func TestOpenChannelDuplicateOutpoints(t *testing.T) {
|
|||
require.ErrorContains(t, err, "duplicate outpoint")
|
||||
}
|
||||
|
||||
// TestOpenChannelSkipsUnconfirmedAutoSelection verifies that automatic coin
|
||||
// selection ignores mempool deposits and keeps using confirmed ones.
|
||||
func TestOpenChannelSkipsUnconfirmedAutoSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmedA := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(1),
|
||||
Value: 160_000,
|
||||
ConfirmationHeight: 10,
|
||||
}
|
||||
confirmedB := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(2),
|
||||
Value: 140_000,
|
||||
ConfirmationHeight: 11,
|
||||
}
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(3),
|
||||
Value: 500_000,
|
||||
}
|
||||
|
||||
depositManager := &mockDepositManager{
|
||||
activeDeposits: []*deposit.Deposit{
|
||||
unconfirmed, confirmedA, confirmedB,
|
||||
},
|
||||
transitionErrs: map[fsm.EventType]error{
|
||||
deposit.OnOpeningChannel: errors.New("stop after selection"),
|
||||
},
|
||||
}
|
||||
manager := &Manager{
|
||||
cfg: &Config{
|
||||
DepositManager: depositManager,
|
||||
},
|
||||
}
|
||||
|
||||
req := &lnrpc.OpenChannelRequest{
|
||||
NodePubkey: make([]byte, 33),
|
||||
LocalFundingAmount: 100_000,
|
||||
SatPerVbyte: 10,
|
||||
}
|
||||
|
||||
_, err := manager.OpenChannel(context.Background(), req)
|
||||
require.ErrorContains(t, err, "stop after selection")
|
||||
require.Len(t, depositManager.calls, 1)
|
||||
require.Equal(t, deposit.OnOpeningChannel, depositManager.calls[0].event)
|
||||
require.NotContains(t, depositManager.calls[0].outpoints, unconfirmed.OutPoint)
|
||||
}
|
||||
|
||||
// TestOpenChannelFundMaxSkipsUnconfirmed verifies that fundmax only locks
|
||||
// confirmed deposits.
|
||||
func TestOpenChannelFundMaxSkipsUnconfirmed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
confirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(1),
|
||||
Value: 200_000,
|
||||
ConfirmationHeight: 10,
|
||||
}
|
||||
unconfirmed := &deposit.Deposit{
|
||||
OutPoint: testOutPoint(2),
|
||||
Value: 300_000,
|
||||
}
|
||||
|
||||
depositManager := &mockDepositManager{
|
||||
activeDeposits: []*deposit.Deposit{
|
||||
unconfirmed, confirmed,
|
||||
},
|
||||
transitionErrs: map[fsm.EventType]error{
|
||||
deposit.OnOpeningChannel: errors.New("stop after selection"),
|
||||
},
|
||||
}
|
||||
manager := &Manager{
|
||||
cfg: &Config{
|
||||
DepositManager: depositManager,
|
||||
},
|
||||
}
|
||||
|
||||
req := &lnrpc.OpenChannelRequest{
|
||||
NodePubkey: make([]byte, 33),
|
||||
FundMax: true,
|
||||
SatPerVbyte: 10,
|
||||
}
|
||||
|
||||
_, err := manager.OpenChannel(context.Background(), req)
|
||||
require.ErrorContains(t, err, "stop after selection")
|
||||
require.Len(t, depositManager.calls, 1)
|
||||
require.Equal(
|
||||
t, []wire.OutPoint{confirmed.OutPoint},
|
||||
depositManager.calls[0].outpoints,
|
||||
)
|
||||
}
|
||||
|
||||
// TestValidateInitialPsbtFlags verifies that request fields incompatible with
|
||||
// PSBT funding are rejected early, before any deposits are locked.
|
||||
func TestValidateInitialPsbtFlags(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -381,6 +381,15 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
for _, d := range deposits {
|
||||
// Deposited now includes mempool outputs for static loop-ins, but
|
||||
// withdrawals still require the deposit input to be confirmed.
|
||||
if d.ConfirmationHeight <= 0 {
|
||||
return "", "", fmt.Errorf("can't withdraw, " +
|
||||
"unconfirmed deposits can't be withdrawn")
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
withdrawalAddress btcutil.Address
|
||||
err error
|
||||
|
|
|
|||
|
|
@ -170,7 +170,9 @@ func (h *mockLightningClient) LookupInvoice(_ context.Context,
|
|||
return nil, fmt.Errorf("invoice: %x not found", hash)
|
||||
}
|
||||
|
||||
return inv, nil
|
||||
invoiceCopy := *inv
|
||||
|
||||
return &invoiceCopy, nil
|
||||
}
|
||||
|
||||
// ListTransactions returns all known transactions of the backing lnd node.
|
||||
|
|
|
|||
|
|
@ -218,6 +218,15 @@ func (s *LndMockServices) AddTx(tx *wire.MsgTx) {
|
|||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
// SetInvoice stores a copy of the given invoice in the mock invoice store.
|
||||
func (s *LndMockServices) SetInvoice(invoice *lndclient.Invoice) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
invoiceCopy := *invoice
|
||||
s.Invoices[invoice.Hash] = &invoiceCopy
|
||||
}
|
||||
|
||||
// IsDone checks whether all channels have been fully emptied. If not this may
|
||||
// indicate unexpected behaviour of the code under test.
|
||||
func (s *LndMockServices) IsDone() error {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue