mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Use each channel's local balance minus its reserve when simulating Loop Out payment bandwidth, clamping the result at zero. This prevents swaps from starting when the gross balance covers the amount and routing fee but the spendable balance does not. Add one-sat boundary tests and a bug-fix release note. Leave a TODO for server-fee and prepay capacity because enforcing their maximum fee caps could reject viable swaps whose actual routing fees are lower.
2203 lines
59 KiB
Go
2203 lines
59 KiB
Go
package loopd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/btcsuite/btclog/v2"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightninglabs/loop/fsm"
|
|
"github.com/lightninglabs/loop/labels"
|
|
"github.com/lightninglabs/loop/liquidity"
|
|
"github.com/lightninglabs/loop/loopdb"
|
|
"github.com/lightninglabs/loop/looprpc"
|
|
"github.com/lightninglabs/loop/staticaddr/address"
|
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
|
"github.com/lightninglabs/loop/staticaddr/loopin"
|
|
"github.com/lightninglabs/loop/staticaddr/script"
|
|
"github.com/lightninglabs/loop/swap"
|
|
mock_lnd "github.com/lightninglabs/loop/test"
|
|
"github.com/lightningnetwork/lnd/input"
|
|
"github.com/lightningnetwork/lnd/lnrpc/invoicesrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/metadata"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
var (
|
|
testnetAddr, _ = btcutil.NewAddressScriptHash(
|
|
[]byte{123}, &chaincfg.TestNet3Params,
|
|
)
|
|
|
|
mainnetAddr, _ = btcutil.NewAddressScriptHash(
|
|
[]byte{123}, &chaincfg.MainNetParams,
|
|
)
|
|
|
|
nodepubkeyAddr, _ = btcutil.DecodeAddress(
|
|
mock_lnd.NewMockLnd().NodePubkey, &chaincfg.MainNetParams,
|
|
)
|
|
|
|
chanID1 = lnwire.NewShortChanIDFromInt(1)
|
|
chanID2 = lnwire.NewShortChanIDFromInt(2)
|
|
chanID3 = lnwire.NewShortChanIDFromInt(3)
|
|
chanID4 = lnwire.NewShortChanIDFromInt(4)
|
|
|
|
peer1 = route.Vertex{1}
|
|
peer2 = route.Vertex{2}
|
|
|
|
channel1 = lndclient.ChannelInfo{
|
|
Active: false,
|
|
ChannelID: chanID1.ToUint64(),
|
|
PubKeyBytes: peer1,
|
|
LocalBalance: 10000,
|
|
RemoteBalance: 0,
|
|
Capacity: 10000,
|
|
}
|
|
|
|
channel2 = lndclient.ChannelInfo{
|
|
Active: true,
|
|
ChannelID: chanID2.ToUint64(),
|
|
PubKeyBytes: peer2,
|
|
LocalBalance: 10000,
|
|
RemoteBalance: 0,
|
|
Capacity: 10000,
|
|
}
|
|
|
|
channel3 = lndclient.ChannelInfo{
|
|
Active: true,
|
|
ChannelID: chanID3.ToUint64(),
|
|
PubKeyBytes: peer2,
|
|
LocalBalance: 10000,
|
|
RemoteBalance: 0,
|
|
Capacity: 10000,
|
|
}
|
|
|
|
channel4 = lndclient.ChannelInfo{
|
|
Active: true,
|
|
ChannelID: chanID4.ToUint64(),
|
|
PubKeyBytes: peer2,
|
|
LocalBalance: 1000,
|
|
RemoteBalance: 0,
|
|
Capacity: 1000,
|
|
}
|
|
)
|
|
|
|
// TestValidateConfTarget tests all failure and success cases for our conf
|
|
// target validation function, including the case where we replace a zero
|
|
// target with the default provided.
|
|
func TestValidateConfTarget(t *testing.T) {
|
|
const (
|
|
// Various input confirmation values for tests.
|
|
zeroConf int32 = 0
|
|
oneConf int32 = 1
|
|
twoConf int32 = 2
|
|
fiveConf int32 = 5
|
|
|
|
// defaultConf is the default confirmation target we use for
|
|
// all tests.
|
|
defaultConf = 6
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
confTarget int32
|
|
expectedTarget int32
|
|
expectErr bool
|
|
}{
|
|
{
|
|
name: "zero conf, get default",
|
|
confTarget: zeroConf,
|
|
expectedTarget: defaultConf,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "one conf, get error",
|
|
confTarget: oneConf,
|
|
expectErr: true,
|
|
},
|
|
{
|
|
name: "two conf, ok",
|
|
confTarget: twoConf,
|
|
expectedTarget: twoConf,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "five conf, ok",
|
|
confTarget: fiveConf,
|
|
expectedTarget: fiveConf,
|
|
expectErr: false,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
target, err := validateConfTarget(
|
|
test.confTarget, defaultConf,
|
|
)
|
|
|
|
if test.expectErr {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.Equal(t, test.expectedTarget, target)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateLoopInRequest tests validation of loop in requests.
|
|
func TestValidateLoopInRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
amount int64
|
|
numDeposits uint32
|
|
external bool
|
|
confTarget int32
|
|
autoSelectDeposits bool
|
|
expectErr bool
|
|
expectedTarget int32
|
|
}{
|
|
{
|
|
name: "external and htlc conf set",
|
|
amount: 100_000,
|
|
external: true,
|
|
confTarget: 1,
|
|
expectErr: true,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "external and no conf",
|
|
amount: 100_000,
|
|
external: true,
|
|
confTarget: 0,
|
|
expectErr: false,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "not external, zero conf",
|
|
amount: 100_000,
|
|
external: false,
|
|
confTarget: 0,
|
|
expectErr: false,
|
|
expectedTarget: loop.DefaultHtlcConfTarget,
|
|
},
|
|
{
|
|
name: "not external, bad conf",
|
|
amount: 100_000,
|
|
external: false,
|
|
confTarget: 1,
|
|
expectErr: true,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "not external, ok conf",
|
|
amount: 100_000,
|
|
external: false,
|
|
confTarget: 5,
|
|
expectErr: false,
|
|
expectedTarget: 5,
|
|
},
|
|
{
|
|
name: "not external, amount no deposit",
|
|
amount: 100_000,
|
|
numDeposits: 0,
|
|
external: false,
|
|
expectErr: false,
|
|
expectedTarget: loop.DefaultHtlcConfTarget,
|
|
},
|
|
{
|
|
name: "not external, deposit no amount",
|
|
amount: 100_000,
|
|
numDeposits: 1,
|
|
external: false,
|
|
expectErr: false,
|
|
},
|
|
|
|
{
|
|
name: "not external, deposit fractional amount",
|
|
amount: 100_000,
|
|
numDeposits: 1,
|
|
external: false,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "amount with deposit coin select",
|
|
amount: 100_000,
|
|
autoSelectDeposits: true,
|
|
external: false,
|
|
expectErr: false,
|
|
},
|
|
{
|
|
name: "amount with deposit coin select",
|
|
numDeposits: 1,
|
|
autoSelectDeposits: true,
|
|
external: false,
|
|
expectErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
external := test.external
|
|
conf, err := validateLoopInRequest(
|
|
test.confTarget, external, test.numDeposits,
|
|
btcutil.Amount(test.amount),
|
|
test.autoSelectDeposits,
|
|
)
|
|
|
|
if test.expectErr {
|
|
require.Error(t, err)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.Equal(t, test.expectedTarget, conf)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStaticAddressLoopInRejectsReservedLabel verifies that external static
|
|
// loop-in requests still reject reserved autoloop labels at the RPC boundary.
|
|
func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) {
|
|
logger := btclog.NewSLogger(
|
|
btclog.NewDefaultHandler(os.Stdout),
|
|
)
|
|
setLogger(logger.SubSystem(Subsystem))
|
|
|
|
server := &swapClientServer{}
|
|
|
|
_, err := server.StaticAddressLoopIn(
|
|
t.Context(), &looprpc.StaticAddressLoopInRequest{
|
|
Label: labels.AutoloopLabel(swap.TypeIn),
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, labels.ErrReservedPrefix.Error())
|
|
}
|
|
|
|
// TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental verifies that
|
|
// users must restart loopd with --experimental before enabling static-address
|
|
// autoloop.
|
|
func TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental(
|
|
t *testing.T) {
|
|
|
|
server := &swapClientServer{
|
|
config: &Config{},
|
|
}
|
|
|
|
_, err := server.SetLiquidityParams(
|
|
t.Context(), &looprpc.SetLiquidityParamsRequest{
|
|
Parameters: &looprpc.LiquidityParameters{
|
|
LoopInSource: looprpc.
|
|
LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
require.Equal(t, codes.FailedPrecondition, status.Code(err))
|
|
require.ErrorContains(t, err, "--experimental")
|
|
}
|
|
|
|
// TestStaticAddressLoopInTimestamp verifies that zero timestamps are omitted
|
|
// from static loop-in responses instead of passing a zero time to UnixNano.
|
|
func TestStaticAddressLoopInTimestamp(t *testing.T) {
|
|
require.Zero(t, staticAddressLoopInTimestamp(time.Time{}))
|
|
|
|
timestamp := time.Unix(1_234, 567).UTC()
|
|
require.Equal(
|
|
t, timestamp.UnixNano(),
|
|
staticAddressLoopInTimestamp(timestamp),
|
|
)
|
|
}
|
|
|
|
// TestStaticAddressLoopInSwapServerCost verifies that static loop-in server
|
|
// costs are only reported once the invoice payment was received. Timeout path
|
|
// costs are not persisted today, so they are intentionally not estimated here.
|
|
func TestStaticAddressLoopInSwapServerCost(t *testing.T) {
|
|
const quoteFee = btcutil.Amount(1_234)
|
|
|
|
tests := []struct {
|
|
name string
|
|
state fsm.StateType
|
|
wantServer int64
|
|
}{
|
|
{
|
|
name: "pending before payment",
|
|
state: loopin.SignHtlcTx,
|
|
},
|
|
{
|
|
name: "payment received",
|
|
state: loopin.PaymentReceived,
|
|
wantServer: int64(quoteFee),
|
|
},
|
|
{
|
|
name: "succeeded",
|
|
state: loopin.Succeeded,
|
|
wantServer: int64(quoteFee),
|
|
},
|
|
{
|
|
name: "succeeded transition failed",
|
|
state: loopin.SucceededTransitioningFailed,
|
|
wantServer: int64(quoteFee),
|
|
},
|
|
{
|
|
name: "timeout swept",
|
|
state: loopin.HtlcTimeoutSwept,
|
|
},
|
|
{
|
|
name: "failed",
|
|
state: loopin.Failed,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
swap := &loopin.StaticAddressLoopIn{
|
|
QuotedSwapFee: quoteFee,
|
|
}
|
|
swap.SetState(test.state)
|
|
|
|
costServer := staticAddressLoopInSwapServerCost(swap)
|
|
|
|
require.Equal(t, test.wantServer, costServer)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestListStaticAddressSwapsPopulatesTimingAndCosts verifies that the RPC
|
|
// response maps stored static loop-in timing fields and cost fields.
|
|
func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) {
|
|
ctx := t.Context()
|
|
lnd := mock_lnd.NewMockLnd()
|
|
|
|
const (
|
|
paymentRequestAmount = btcutil.Amount(50_000)
|
|
quotedSwapFee = btcutil.Amount(1_234)
|
|
depositValue = btcutil.Amount(51_234)
|
|
depositConfHeight = int64(590)
|
|
staticAddressExpiry = uint32(25)
|
|
)
|
|
|
|
_, swapInvoice, err := lnd.Client.AddInvoice(
|
|
ctx, &invoicesrpc.AddInvoiceData{
|
|
Value: lnwire.NewMSatFromSatoshis(paymentRequestAmount),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
swapHash := lntypes.Hash{1, 2, 3}
|
|
depositID := deposit.ID{4, 5, 6}
|
|
depositOutpoint := wire.OutPoint{
|
|
Hash: chainhash.Hash{7, 8, 9},
|
|
Index: 1,
|
|
}
|
|
testDeposit := &deposit.Deposit{
|
|
ID: depositID,
|
|
OutPoint: depositOutpoint,
|
|
Value: depositValue,
|
|
ConfirmationHeight: depositConfHeight,
|
|
SwapHash: &swapHash,
|
|
}
|
|
testDeposit.SetState(deposit.LoopedIn)
|
|
|
|
initiationTime := time.Unix(1_234, 567).UTC()
|
|
lastUpdateTime := time.Unix(2_345, 678).UTC()
|
|
staticLoopIn := &loopin.StaticAddressLoopIn{
|
|
SwapHash: swapHash,
|
|
SwapInvoice: swapInvoice,
|
|
InitiationTime: initiationTime,
|
|
LastUpdateTime: lastUpdateTime,
|
|
QuotedSwapFee: quotedSwapFee,
|
|
DepositOutpoints: []string{depositOutpoint.String()},
|
|
Deposits: []*deposit.Deposit{testDeposit},
|
|
}
|
|
staticLoopIn.SetState(loopin.Succeeded)
|
|
|
|
depositStore := &mockDepositStore{
|
|
byOutpoint: map[string]*deposit.Deposit{
|
|
depositOutpoint.String(): testDeposit,
|
|
},
|
|
}
|
|
depositMgr := deposit.NewManager(&deposit.ManagerConfig{
|
|
Store: depositStore,
|
|
})
|
|
|
|
staticLoopInMgr, err := loopin.NewManager(&loopin.Config{
|
|
Store: &mockStaticAddressLoopInStore{
|
|
swaps: []*loopin.StaticAddressLoopIn{staticLoopIn},
|
|
},
|
|
DepositManager: depositMgr,
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
_, clientPubkey := mock_lnd.CreateKey(1)
|
|
_, serverPubkey := mock_lnd.CreateKey(2)
|
|
addrStore := &mockAddressStore{
|
|
params: []*script.Parameters{{
|
|
ClientPubkey: clientPubkey,
|
|
ServerPubkey: serverPubkey,
|
|
Expiry: staticAddressExpiry,
|
|
PkScript: []byte("pkscript"),
|
|
}},
|
|
}
|
|
addrMgr, err := address.NewManager(&address.ManagerConfig{
|
|
Store: addrStore,
|
|
WalletKit: lnd.WalletKit,
|
|
ChainParams: lnd.ChainParams,
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
server := &swapClientServer{
|
|
network: lndclient.NetworkTestnet,
|
|
lnd: &lnd.LndServices,
|
|
staticAddressManager: addrMgr,
|
|
depositManager: depositMgr,
|
|
staticLoopInManager: staticLoopInMgr,
|
|
}
|
|
resp, err := server.ListStaticAddressSwaps(
|
|
ctx, &looprpc.ListStaticAddressSwapsRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.Swaps, 1)
|
|
|
|
swap := resp.Swaps[0]
|
|
require.Equal(t, swapHash[:], swap.SwapHash)
|
|
require.Equal(t, []string{depositOutpoint.String()}, swap.DepositOutpoints)
|
|
require.Equal(
|
|
t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED, swap.State,
|
|
)
|
|
require.Equal(t, int64(depositValue), swap.SwapAmountSatoshis)
|
|
require.Equal(
|
|
t, int64(paymentRequestAmount), swap.PaymentRequestAmountSatoshis,
|
|
)
|
|
require.Equal(t, initiationTime.UnixNano(), swap.InitiationTime)
|
|
require.Equal(t, lastUpdateTime.UnixNano(), swap.LastUpdateTime)
|
|
require.Equal(t, int64(quotedSwapFee), swap.CostServer)
|
|
require.Zero(t, swap.CostOnchain)
|
|
require.Zero(t, swap.CostOffchain)
|
|
require.Len(t, swap.Deposits, 1)
|
|
|
|
rpcDeposit := swap.Deposits[0]
|
|
require.Equal(t, depositID[:], rpcDeposit.Id)
|
|
require.Equal(t, depositOutpoint.String(), rpcDeposit.Outpoint)
|
|
require.Equal(t, int64(depositValue), rpcDeposit.Value)
|
|
require.Equal(t, depositConfHeight, rpcDeposit.ConfirmationHeight)
|
|
require.Equal(t, swapHash[:], rpcDeposit.SwapHash)
|
|
require.Equal(t, looprpc.DepositState_LOOPED_IN, rpcDeposit.State)
|
|
require.Equal(
|
|
t, depositConfHeight+int64(staticAddressExpiry)-600,
|
|
rpcDeposit.BlocksUntilExpiry,
|
|
)
|
|
}
|
|
|
|
// TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH protects the RPC
|
|
// mapping invariant that static loop-ins expose their static type, static
|
|
// state, and P2WSH HTLC address without leaking a taproot HTLC address.
|
|
func TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH(t *testing.T) {
|
|
server := &swapClientServer{}
|
|
loopSwap := &loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
State: loopdb.StateInitiated,
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
InitiationTime: time.Now(),
|
|
},
|
|
LastUpdate: time.Now(),
|
|
SwapHash: lntypes.Hash{1},
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
StaticAddressLoopInState: loopin.SignHtlcTx,
|
|
HtlcAddressP2WSH: testnetAddr,
|
|
}
|
|
|
|
rpcSwap, err := server.marshallSwap(t.Context(), loopSwap)
|
|
require.NoError(t, err)
|
|
require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, rpcSwap.Type)
|
|
require.Equal(
|
|
t, looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX,
|
|
rpcSwap.GetStaticLoopInState(),
|
|
)
|
|
require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State)
|
|
require.Equal(t, testnetAddr.EncodeAddress(), rpcSwap.HtlcAddressP2Wsh)
|
|
require.Empty(t, rpcSwap.HtlcAddressP2Tr)
|
|
}
|
|
|
|
// TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault asserts that
|
|
// static loop-in failures keep default legacy fields while preserving the
|
|
// precise static state.
|
|
func TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault(
|
|
t *testing.T) {
|
|
|
|
tests := []struct {
|
|
name string
|
|
state fsm.StateType
|
|
wantStaticState looprpc.StaticAddressLoopInSwapState
|
|
}{
|
|
{
|
|
name: "failed",
|
|
state: loopin.Failed,
|
|
wantStaticState: looprpc.
|
|
StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP,
|
|
},
|
|
{
|
|
name: "succeeded transitioning failed",
|
|
state: loopin.SucceededTransitioningFailed,
|
|
wantStaticState: looprpc.
|
|
StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
staticLoopIn.SetState(test.state)
|
|
loopSwap, err := server.staticAddressLoopInSwapInfo(
|
|
t.Context(), staticLoopIn,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
rpcSwap, err := server.marshallSwap(t.Context(), loopSwap)
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State)
|
|
require.Equal(
|
|
t, looprpc.FailureReason_FAILURE_REASON_NONE,
|
|
rpcSwap.FailureReason,
|
|
)
|
|
require.Equal(
|
|
t, test.wantStaticState,
|
|
rpcSwap.GetStaticLoopInState(),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress protects the
|
|
// fail-closed HTLC-address invariant for static loop-ins missing the P2WSH
|
|
// address required by the client-facing RPC representation.
|
|
func TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress(t *testing.T) {
|
|
_, taprootAddress := newTestStaticAddressParams(t)
|
|
server := &swapClientServer{}
|
|
loopSwap := &loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
State: loopdb.StateInitiated,
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
InitiationTime: time.Now(),
|
|
},
|
|
LastUpdate: time.Now(),
|
|
SwapHash: lntypes.Hash{1},
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
StaticAddressLoopInState: loopin.SignHtlcTx,
|
|
HtlcAddressP2TR: taprootAddress,
|
|
}
|
|
|
|
_, err := server.marshallSwap(t.Context(), loopSwap)
|
|
require.ErrorContains(t, err, "missing static address loop-in P2WSH HTLC address")
|
|
}
|
|
|
|
// TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing protects the
|
|
// HTLC-address construction invariant that missing cooperative keys must not
|
|
// produce monitorable swap info.
|
|
func TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing(t *testing.T) {
|
|
server, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
staticLoopIn.ClientPubkey = nil
|
|
|
|
_, err := server.staticAddressLoopInSwapInfo(t.Context(), staticLoopIn)
|
|
require.ErrorContains(
|
|
t, err, "missing static address loop-in client HTLC key",
|
|
)
|
|
}
|
|
|
|
// TestMonitorSnapshotIncludesStaticAddressLoopIns protects the monitor snapshot
|
|
// invariant that pending static loop-ins are included alongside cached generic
|
|
// swaps with their static state and swap-specific HTLC address.
|
|
func TestMonitorSnapshotIncludesStaticAddressLoopIns(t *testing.T) {
|
|
ctx := t.Context()
|
|
server, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
|
|
pendingSwaps, completedSwaps := server.monitorCachedSwaps()
|
|
err := server.appendStaticAddressLoopInMonitorSnapshot(
|
|
ctx, &pendingSwaps, &completedSwaps,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Empty(t, completedSwaps)
|
|
require.Len(t, pendingSwaps, 1)
|
|
require.Equal(t, staticLoopIn.SwapHash, pendingSwaps[0].SwapHash)
|
|
require.Equal(t, swap.TypeStaticAddressLoopIn, pendingSwaps[0].SwapType)
|
|
require.Equal(
|
|
t, staticLoopIn.GetState(),
|
|
pendingSwaps[0].StaticAddressLoopInState,
|
|
)
|
|
assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, pendingSwaps[0])
|
|
}
|
|
|
|
// TestMonitorSnapshotIncludesFinalStaticAddressLoopIns protects the monitor
|
|
// snapshot invariant that exact final static loop-in states are completed swaps.
|
|
func TestMonitorSnapshotIncludesFinalStaticAddressLoopIns(t *testing.T) {
|
|
server, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
staticLoopIn.SetState(loopin.Succeeded)
|
|
|
|
pendingSwaps, completedSwaps := server.monitorCachedSwaps()
|
|
err := server.appendStaticAddressLoopInMonitorSnapshot(
|
|
t.Context(), &pendingSwaps, &completedSwaps,
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.Empty(t, pendingSwaps)
|
|
require.Len(t, completedSwaps, 1)
|
|
}
|
|
|
|
// TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress protects the live-update
|
|
// invariant that static loop-in status events derive the HTLC address from the
|
|
// swap, not from reusable static address parameters.
|
|
func TestStaticLoopInStatusUpdaterUsesSwapHtlcAddress(t *testing.T) {
|
|
ctx := t.Context()
|
|
_, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
staticLoopIn.AddressParams = nil
|
|
statusChan := make(chan loop.SwapInfo, 1)
|
|
updater := &staticLoopInStatusUpdater{
|
|
statusChan: statusChan,
|
|
mainCtx: ctx,
|
|
chainParams: &chaincfg.TestNet3Params,
|
|
}
|
|
|
|
err := updater.sendUpdate(ctx, staticLoopIn)
|
|
require.NoError(t, err)
|
|
swapInfo := <-statusChan
|
|
assertStaticLoopInUsesSwapHtlcAddress(t, staticLoopIn, swapInfo)
|
|
}
|
|
|
|
// TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate protects the
|
|
// monitor race invariant that live static loop-in updates arriving during the
|
|
// initial snapshot are deduplicated without dropping newer progress.
|
|
func TestMonitorSuppressesStaticAddressLoopInSnapshotLiveDuplicate(t *testing.T) {
|
|
logger := btclog.NewSLogger(
|
|
btclog.NewDefaultHandler(os.Stdout),
|
|
)
|
|
setLogger(logger.SubSystem(Subsystem))
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
mainCtx, mainCancel := context.WithCancel(t.Context())
|
|
defer mainCancel()
|
|
server, staticLoopIn, store := newGenericStaticLoopInServerWithStore(t)
|
|
server.statusChan = make(chan loop.SwapInfo)
|
|
server.subscribers = make(map[int]chan<- any)
|
|
server.mainCtx = mainCtx
|
|
|
|
snapshotStarted := make(chan struct{}, 1)
|
|
releaseSnapshot := make(chan struct{})
|
|
store.beforeGet = func() {
|
|
select {
|
|
case snapshotStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
store.waitGet = releaseSnapshot
|
|
|
|
go server.processStatusUpdates(mainCtx)
|
|
|
|
monitorServer := &testMonitorServer{
|
|
ctx: ctx,
|
|
sent: make(chan *looprpc.SwapStatus, 3),
|
|
}
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
errChan <- server.Monitor(&looprpc.MonitorRequest{}, monitorServer)
|
|
}()
|
|
|
|
select {
|
|
case <-snapshotStarted:
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
|
|
staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn)
|
|
require.NoError(t, err)
|
|
staleUpdate := *staticUpdate
|
|
staleUpdate.State = loopdb.StateInitiated
|
|
staleUpdate.LastUpdate = staticUpdate.LastUpdate.Add(-time.Second)
|
|
server.statusChan <- staleUpdate
|
|
|
|
server.statusChan <- *staticUpdate
|
|
close(releaseSnapshot)
|
|
|
|
first := receiveMonitorUpdate(t, ctx, monitorServer.sent)
|
|
require.Equal(t, staticLoopIn.SwapHash[:], first.IdBytes)
|
|
require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, first.Type)
|
|
require.Equal(
|
|
t, looprpc.StaticAddressLoopInSwapState_PAYMENT_RECEIVED,
|
|
first.GetStaticLoopInState(),
|
|
)
|
|
|
|
nextUpdate := *staticUpdate
|
|
nextUpdate.State = loopdb.StateSuccess
|
|
nextUpdate.StaticAddressLoopInState = loopin.Succeeded
|
|
nextUpdate.LastUpdate = staticUpdate.LastUpdate.Add(time.Second)
|
|
server.statusChan <- nextUpdate
|
|
|
|
second := receiveMonitorUpdate(t, ctx, monitorServer.sent)
|
|
require.Equal(t, staticLoopIn.SwapHash[:], second.IdBytes)
|
|
require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, second.Type)
|
|
require.Equal(
|
|
t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED,
|
|
second.GetStaticLoopInState(),
|
|
)
|
|
|
|
cancel()
|
|
require.NoError(t, <-errChan)
|
|
}
|
|
|
|
// TestStaticAddressLoopInHighWaterSuppressesExactDuplicate protects the
|
|
// high-water dedup invariant that an initial live update identical to the
|
|
// snapshot is treated as stale.
|
|
func TestStaticAddressLoopInHighWaterSuppressesExactDuplicate(t *testing.T) {
|
|
swapHash := lntypes.Hash{1, 2, 3}
|
|
lastUpdate := time.Unix(100, 0).UTC()
|
|
snapshot := loop.SwapInfo{
|
|
SwapHash: swapHash,
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
LastUpdate: lastUpdate,
|
|
StaticAddressLoopInState: loopin.PaymentReceived,
|
|
}
|
|
highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
|
|
snapshot,
|
|
})
|
|
|
|
isStale := isInitialStaticAddressLoopInStale(highWater, snapshot)
|
|
|
|
require.True(t, isStale)
|
|
}
|
|
|
|
// TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState protects the
|
|
// high-water timing invariant that equal timestamps do not hide a distinct
|
|
// static loop-in state transition.
|
|
func TestStaticAddressLoopInHighWaterKeepsSameTimeDifferentState(t *testing.T) {
|
|
swapHash := lntypes.Hash{1, 2, 3}
|
|
lastUpdate := time.Unix(100, 0).UTC()
|
|
snapshot := loop.SwapInfo{
|
|
SwapHash: swapHash,
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
LastUpdate: lastUpdate,
|
|
StaticAddressLoopInState: loopin.PaymentReceived,
|
|
}
|
|
liveUpdate := snapshot
|
|
liveUpdate.StaticAddressLoopInState = loopin.Succeeded
|
|
highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
|
|
snapshot,
|
|
})
|
|
|
|
isStale := isInitialStaticAddressLoopInStale(highWater, liveUpdate)
|
|
|
|
require.False(t, isStale)
|
|
}
|
|
|
|
// TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly protects the
|
|
// high-water cache invariant that stale suppression applies only to static
|
|
// loop-ins and cannot filter generic swap updates.
|
|
func TestStaticAddressLoopInHighWaterSuppressesOlderStaticOnly(t *testing.T) {
|
|
swapHash := lntypes.Hash{1, 2, 3}
|
|
lastUpdate := time.Unix(100, 0).UTC()
|
|
snapshot := loop.SwapInfo{
|
|
SwapHash: swapHash,
|
|
SwapType: swap.TypeStaticAddressLoopIn,
|
|
LastUpdate: lastUpdate,
|
|
StaticAddressLoopInState: loopin.PaymentReceived,
|
|
}
|
|
highWater := staticAddressLoopInMonitorHighWater([]loop.SwapInfo{
|
|
snapshot,
|
|
})
|
|
olderStatic := snapshot
|
|
olderStatic.LastUpdate = lastUpdate.Add(-time.Second)
|
|
olderStatic.StaticAddressLoopInState = loopin.Succeeded
|
|
nonStatic := olderStatic
|
|
nonStatic.SwapType = swap.TypeOut
|
|
|
|
staticStale := isInitialStaticAddressLoopInStale(highWater, olderStatic)
|
|
nonStaticStale := isInitialStaticAddressLoopInStale(
|
|
highWater, nonStatic,
|
|
)
|
|
|
|
require.True(t, staticStale)
|
|
require.False(t, nonStaticStale)
|
|
}
|
|
|
|
// TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache protects the
|
|
// cache isolation invariant that static loop-in live updates reach subscribers
|
|
// without entering the generic swap cache.
|
|
func TestStaticAddressLoopInStatusUpdateDoesNotEnterGenericSwapCache(t *testing.T) {
|
|
ctx := t.Context()
|
|
server, staticLoopIn := newGenericStaticLoopInServer(t)
|
|
server.statusChan = make(chan loop.SwapInfo)
|
|
updates := make(chan any, 1)
|
|
server.subscribers = map[int]chan<- any{0: updates}
|
|
mainCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
go server.processStatusUpdates(mainCtx)
|
|
|
|
staticUpdate, err := server.staticAddressLoopInSwapInfo(ctx, staticLoopIn)
|
|
require.NoError(t, err)
|
|
server.statusChan <- *staticUpdate
|
|
|
|
select {
|
|
case update := <-updates:
|
|
require.Equal(t, staticLoopIn.SwapHash, update.(loop.SwapInfo).SwapHash)
|
|
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
|
|
server.swapsLock.Lock()
|
|
_, cached := server.swaps[staticLoopIn.SwapHash]
|
|
server.swapsLock.Unlock()
|
|
require.False(t, cached)
|
|
}
|
|
|
|
func newGenericStaticLoopInServer(t *testing.T) (*swapClientServer,
|
|
*loopin.StaticAddressLoopIn) {
|
|
|
|
server, staticLoopIn, _ := newGenericStaticLoopInServerWithStore(t)
|
|
|
|
return server, staticLoopIn
|
|
}
|
|
|
|
func newTestStaticAddressParams(t *testing.T) (*script.Parameters,
|
|
*btcutil.AddressTaproot) {
|
|
|
|
t.Helper()
|
|
|
|
const staticAddressExpiry = uint32(25)
|
|
|
|
_, staticClientPubkey := mock_lnd.CreateKey(12)
|
|
_, staticServerPubkey := mock_lnd.CreateKey(13)
|
|
staticAddress, err := script.NewStaticAddress(
|
|
input.MuSig2Version100RC2, int64(staticAddressExpiry),
|
|
staticClientPubkey, staticServerPubkey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
staticPkScript, err := staticAddress.StaticAddressScript()
|
|
require.NoError(t, err)
|
|
|
|
taprootAddress, err := btcutil.NewAddressTaproot(
|
|
schnorr.SerializePubKey(staticAddress.TaprootKey),
|
|
&chaincfg.TestNet3Params,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
return &script.Parameters{
|
|
ClientPubkey: staticClientPubkey,
|
|
ServerPubkey: staticServerPubkey,
|
|
Expiry: staticAddressExpiry,
|
|
PkScript: staticPkScript,
|
|
}, taprootAddress
|
|
}
|
|
|
|
func newGenericStaticLoopInServerWithStore(t *testing.T) (*swapClientServer,
|
|
*loopin.StaticAddressLoopIn, *mockStaticAddressLoopInStore) {
|
|
|
|
t.Helper()
|
|
|
|
_, clientPubkey := mock_lnd.CreateKey(10)
|
|
_, serverPubkey := mock_lnd.CreateKey(11)
|
|
addressParams, _ := newTestStaticAddressParams(t)
|
|
depositOutpoint := wire.OutPoint{
|
|
Hash: chainhash.Hash{12, 13, 14},
|
|
Index: 2,
|
|
}
|
|
staticDeposit := &deposit.Deposit{
|
|
OutPoint: depositOutpoint,
|
|
Value: 51_000,
|
|
}
|
|
lastHop := route.Vertex{7, 8, 9}
|
|
|
|
staticLoopIn := &loopin.StaticAddressLoopIn{
|
|
SwapHash: lntypes.Hash{1, 2, 3},
|
|
HtlcCltvExpiry: 700,
|
|
InitiationTime: time.Unix(100, 0).UTC(),
|
|
LastUpdateTime: time.Unix(200, 0).UTC(),
|
|
Label: "static-loop-in",
|
|
ClientPubkey: clientPubkey,
|
|
ServerPubkey: serverPubkey,
|
|
LastHop: lastHop[:],
|
|
QuotedSwapFee: 1_111,
|
|
SelectedAmount: 50_000,
|
|
DepositOutpoints: []string{depositOutpoint.String()},
|
|
Deposits: []*deposit.Deposit{staticDeposit},
|
|
AddressParams: addressParams,
|
|
}
|
|
staticLoopIn.SetState(loopin.PaymentReceived)
|
|
|
|
depositStore := &mockDepositStore{
|
|
byOutpoint: map[string]*deposit.Deposit{
|
|
depositOutpoint.String(): staticDeposit,
|
|
},
|
|
}
|
|
loopInStore := &mockStaticAddressLoopInStore{
|
|
swaps: []*loopin.StaticAddressLoopIn{staticLoopIn},
|
|
}
|
|
staticLoopInManager, err := loopin.NewManager(&loopin.Config{
|
|
Store: loopInStore,
|
|
DepositManager: deposit.NewManager(&deposit.ManagerConfig{
|
|
Store: depositStore,
|
|
}),
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
return &swapClientServer{
|
|
network: lndclient.NetworkTestnet,
|
|
swaps: make(map[lntypes.Hash]loop.SwapInfo),
|
|
staticLoopInManager: staticLoopInManager,
|
|
}, staticLoopIn, loopInStore
|
|
}
|
|
|
|
// assertStaticLoopInUsesSwapHtlcAddress verifies the static loop-in uses the
|
|
// swap HTLC P2WSH address expected by the fixture.
|
|
func assertStaticLoopInUsesSwapHtlcAddress(t *testing.T,
|
|
staticLoopIn *loopin.StaticAddressLoopIn, swapInfo loop.SwapInfo) {
|
|
|
|
t.Helper()
|
|
|
|
expectedAddress, err := staticAddressLoopInHtlcAddress(
|
|
staticLoopIn, &chaincfg.TestNet3Params,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Nil(t, swapInfo.HtlcAddressP2TR)
|
|
require.NotNil(t, swapInfo.HtlcAddressP2WSH)
|
|
require.Equal(
|
|
t, expectedAddress.EncodeAddress(),
|
|
swapInfo.HtlcAddressP2WSH.EncodeAddress(),
|
|
)
|
|
}
|
|
|
|
// testMonitorServer implements the monitor stream interface for tests.
|
|
type testMonitorServer struct {
|
|
ctx context.Context
|
|
sent chan *looprpc.SwapStatus
|
|
}
|
|
|
|
// Send forwards monitor updates to the test channel until the context is canceled.
|
|
func (s *testMonitorServer) Send(swapStatus *looprpc.SwapStatus) error {
|
|
select {
|
|
case s.sent <- swapStatus:
|
|
return nil
|
|
|
|
case <-s.ctx.Done():
|
|
return s.ctx.Err()
|
|
}
|
|
}
|
|
|
|
// SetHeader is a no-op stub that satisfies the monitor stream interface in tests.
|
|
func (s *testMonitorServer) SetHeader(metadata.MD) error {
|
|
return nil
|
|
}
|
|
|
|
// SendHeader is a no-op stub that satisfies the monitor stream interface in tests.
|
|
func (s *testMonitorServer) SendHeader(metadata.MD) error {
|
|
return nil
|
|
}
|
|
|
|
// SetTrailer is a no-op stub that satisfies the monitor stream interface in tests.
|
|
func (s *testMonitorServer) SetTrailer(metadata.MD) {}
|
|
|
|
// Context returns the stream context used by the test monitor server.
|
|
func (s *testMonitorServer) Context() context.Context {
|
|
return s.ctx
|
|
}
|
|
|
|
// SendMsg is a no-op stub that satisfies the monitor stream interface in tests.
|
|
func (s *testMonitorServer) SendMsg(any) error {
|
|
return nil
|
|
}
|
|
|
|
// RecvMsg is a no-op stub that satisfies the monitor stream interface in tests.
|
|
func (s *testMonitorServer) RecvMsg(any) error {
|
|
return nil
|
|
}
|
|
|
|
// receiveMonitorUpdate waits for a monitor update or fails if the context is canceled.
|
|
func receiveMonitorUpdate(t *testing.T, ctx context.Context,
|
|
updates <-chan *looprpc.SwapStatus) *looprpc.SwapStatus {
|
|
|
|
t.Helper()
|
|
|
|
select {
|
|
case update := <-updates:
|
|
return update
|
|
|
|
case <-ctx.Done():
|
|
t.Fatal(ctx.Err())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC
|
|
// response mapping tests.
|
|
type mockStaticAddressLoopInStore struct {
|
|
swaps []*loopin.StaticAddressLoopIn
|
|
beforeGet func()
|
|
waitGet <-chan struct{}
|
|
}
|
|
|
|
// CreateLoopIn satisfies the static loop-in store interface.
|
|
func (s *mockStaticAddressLoopInStore) CreateLoopIn(_ context.Context,
|
|
_ *loopin.StaticAddressLoopIn) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateLoopIn satisfies the static loop-in store interface.
|
|
func (s *mockStaticAddressLoopInStore) UpdateLoopIn(_ context.Context,
|
|
_ *loopin.StaticAddressLoopIn) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetStaticAddressLoopInSwapsByStates returns the configured loop-ins.
|
|
func (s *mockStaticAddressLoopInStore) GetStaticAddressLoopInSwapsByStates(
|
|
ctx context.Context, _ []fsm.StateType) ([]*loopin.StaticAddressLoopIn,
|
|
error) {
|
|
|
|
if s.beforeGet != nil {
|
|
s.beforeGet()
|
|
}
|
|
if s.waitGet != nil {
|
|
select {
|
|
case <-s.waitGet:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
return s.swaps, nil
|
|
}
|
|
|
|
// IsStored satisfies the static loop-in store interface.
|
|
func (s *mockStaticAddressLoopInStore) IsStored(_ context.Context,
|
|
_ lntypes.Hash) (bool, error) {
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// RecordStaticAddressRiskDecision satisfies the static loop-in store interface.
|
|
func (s *mockStaticAddressLoopInStore) RecordStaticAddressRiskDecision(
|
|
_ context.Context, _ lntypes.Hash,
|
|
_ loopin.ConfirmationRiskDecision) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetLoopInByHash returns the configured loop-in with the given hash.
|
|
func (s *mockStaticAddressLoopInStore) GetLoopInByHash(_ context.Context,
|
|
swapHash lntypes.Hash) (*loopin.StaticAddressLoopIn, error) {
|
|
|
|
for _, swp := range s.swaps {
|
|
if swp.SwapHash == swapHash {
|
|
return swp, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// SwapHashesForDepositIDs satisfies the static loop-in store interface.
|
|
func (s *mockStaticAddressLoopInStore) SwapHashesForDepositIDs(
|
|
_ context.Context, _ []deposit.ID) (map[lntypes.Hash][]deposit.ID,
|
|
error) {
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner
|
|
// reason is exposed over rpc.
|
|
func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) {
|
|
reason, err := rpcAutoloopReason(
|
|
liquidity.ReasonStaticLoopInNoCandidate,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t,
|
|
looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE,
|
|
reason,
|
|
)
|
|
}
|
|
|
|
// TestRPCAutoloopReasonCustomChannelData verifies that custom-channel
|
|
// disqualifications are exposed over rpc instead of failing the whole dry run.
|
|
func TestRPCAutoloopReasonCustomChannelData(t *testing.T) {
|
|
reason, err := rpcAutoloopReason(liquidity.ReasonCustomChannelData)
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t, looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, reason,
|
|
)
|
|
}
|
|
|
|
// TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the
|
|
// daemon shutdown.
|
|
func TestSwapClientServerStopDaemon(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Prepare a server instance that tracks whether shutdown is requested.
|
|
var stopCalled bool
|
|
server := &swapClientServer{
|
|
stopDaemon: func() {
|
|
stopCalled = true
|
|
},
|
|
}
|
|
|
|
// Request the daemon to stop and assert the callback executed.
|
|
_, err := server.StopDaemon(
|
|
context.Background(), &looprpc.StopDaemonRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Ensure our shutdown callback executed.
|
|
require.True(t, stopCalled)
|
|
}
|
|
|
|
// TestValidateLoopOutRequest tests validation of loop out requests.
|
|
func TestValidateLoopOutRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
chain chaincfg.Params
|
|
confTarget int32
|
|
destAddr btcutil.Address
|
|
label string
|
|
channels []lndclient.ChannelInfo
|
|
outgoingChanSet []uint64
|
|
amount int64
|
|
maxRoutingFee int64
|
|
maxParts uint32
|
|
err error
|
|
expectedTarget int32
|
|
}{
|
|
{
|
|
name: "mainnet address with mainnet backend",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "mainnet address with testnet backend",
|
|
chain: chaincfg.TestNet3Params,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: errIncorrectChain,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "testnet address with testnet backend",
|
|
chain: chaincfg.TestNet3Params,
|
|
destAddr: testnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "testnet address with mainnet backend",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: testnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: errIncorrectChain,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "invalid label",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: labels.Reserved,
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: labels.ErrReservedPrefix,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "invalid conf target",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 1,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: errConfTargetTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "default conf target",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 0,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxParts: 5,
|
|
err: nil,
|
|
expectedTarget: 9,
|
|
},
|
|
{
|
|
name: "valid amount for default channel set",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel1, channel2, channel3,
|
|
},
|
|
amount: 20000,
|
|
maxParts: 5,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "invalid amount for default channel set",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel1, channel2, channel3,
|
|
},
|
|
amount: 25000,
|
|
maxParts: 5,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "inactive channel in outgoing channel set",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel1, channel2, channel3,
|
|
},
|
|
outgoingChanSet: []uint64{
|
|
chanID1.ToUint64(),
|
|
},
|
|
amount: 1000,
|
|
maxParts: 5,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "outgoing channel set balance is enough",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel1, channel2, channel3,
|
|
},
|
|
outgoingChanSet: []uint64{
|
|
chanID2.ToUint64(),
|
|
},
|
|
amount: 1000,
|
|
maxParts: 5,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "outgoing channel set balance not sufficient",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel1, channel2, channel3,
|
|
},
|
|
outgoingChanSet: []uint64{
|
|
chanID2.ToUint64(),
|
|
},
|
|
amount: 20000,
|
|
maxParts: 5,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "amount with routing fee too high",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2,
|
|
},
|
|
amount: 10000,
|
|
maxRoutingFee: 100,
|
|
maxParts: 5,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "channel reserve leaves balance one sat short",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
Active: true,
|
|
ChannelID: chanID2.ToUint64(),
|
|
LocalBalance: 10100,
|
|
LocalConstraints: &lndclient.ChannelConstraints{
|
|
Reserve: 100,
|
|
},
|
|
},
|
|
},
|
|
amount: 10000,
|
|
maxRoutingFee: 1,
|
|
maxParts: 1,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "channel reserve leaves exact balance",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
Active: true,
|
|
ChannelID: chanID2.ToUint64(),
|
|
LocalBalance: 10101,
|
|
LocalConstraints: &lndclient.ChannelConstraints{
|
|
Reserve: 100,
|
|
},
|
|
},
|
|
},
|
|
amount: 10000,
|
|
maxRoutingFee: 1,
|
|
maxParts: 1,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "can split between channels",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2, channel4,
|
|
},
|
|
amount: 11000,
|
|
maxParts: 16,
|
|
err: nil,
|
|
expectedTarget: 2,
|
|
},
|
|
{
|
|
name: "can't split between channels",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: mainnetAddr,
|
|
label: "label ok",
|
|
confTarget: 2,
|
|
channels: []lndclient.ChannelInfo{
|
|
channel2, channel4,
|
|
},
|
|
amount: 11000,
|
|
maxParts: 5,
|
|
err: errBalanceTooLow,
|
|
expectedTarget: 0,
|
|
},
|
|
{
|
|
name: "node pubkey as dest addr",
|
|
chain: chaincfg.MainNetParams,
|
|
destAddr: nodepubkeyAddr,
|
|
err: errInvalidAddress,
|
|
expectedTarget: 0,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
lnd := mock_lnd.NewMockLnd()
|
|
lnd.Channels = test.channels
|
|
|
|
req := &looprpc.LoopOutRequest{
|
|
Amt: test.amount,
|
|
MaxSwapRoutingFee: test.maxRoutingFee,
|
|
OutgoingChanSet: test.outgoingChanSet,
|
|
Label: test.label,
|
|
SweepConfTarget: test.confTarget,
|
|
}
|
|
|
|
logger := btclog.NewSLogger(
|
|
btclog.NewDefaultHandler(os.Stdout),
|
|
)
|
|
setLogger(logger.SubSystem(Subsystem))
|
|
|
|
conf, err := validateLoopOutRequest(
|
|
ctx, lnd.Client, &test.chain, req,
|
|
test.destAddr, test.maxParts,
|
|
)
|
|
require.ErrorIs(t, err, test.err)
|
|
require.Equal(t, test.expectedTarget, conf)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHasBandwidth tests that the hasBandwidth function correctly simulates
|
|
// the MPP logic used by LND.
|
|
func TestHasBandwidth(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
channels []lndclient.ChannelInfo
|
|
maxParts int
|
|
amt btcutil.Amount
|
|
expectedRes bool
|
|
expectedShards int
|
|
}{
|
|
{
|
|
name: "can route due to high number of parts",
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
LocalBalance: 100,
|
|
},
|
|
{
|
|
LocalBalance: 10,
|
|
},
|
|
},
|
|
maxParts: 11,
|
|
amt: 110,
|
|
expectedRes: true,
|
|
expectedShards: 8,
|
|
},
|
|
{
|
|
name: "can't route due to low number of parts",
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
LocalBalance: 100,
|
|
},
|
|
{
|
|
LocalBalance: 10,
|
|
},
|
|
},
|
|
maxParts: 5,
|
|
amt: 110,
|
|
expectedRes: false,
|
|
},
|
|
{
|
|
name: "can route",
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
LocalBalance: 1000,
|
|
},
|
|
{
|
|
LocalBalance: 1000,
|
|
},
|
|
},
|
|
maxParts: 5,
|
|
amt: 2000,
|
|
expectedRes: true,
|
|
expectedShards: 2,
|
|
},
|
|
{
|
|
name: "can route",
|
|
channels: []lndclient.ChannelInfo{
|
|
{
|
|
LocalBalance: 100,
|
|
},
|
|
{
|
|
LocalBalance: 100,
|
|
},
|
|
{
|
|
LocalBalance: 100,
|
|
},
|
|
},
|
|
maxParts: 10,
|
|
amt: 300,
|
|
expectedRes: true,
|
|
expectedShards: 10,
|
|
},
|
|
{
|
|
name: "can't route due to empty channel set",
|
|
maxParts: 10,
|
|
amt: 300,
|
|
expectedRes: false,
|
|
expectedShards: 0,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
res, shards := hasBandwidth(test.channels, test.amt,
|
|
test.maxParts)
|
|
require.Equal(t, test.expectedRes, res)
|
|
require.Equal(t, test.expectedShards, shards)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestListSwapsFilterAndPagination tests the filtering and
|
|
// paging of the ListSwaps command.
|
|
func TestListSwapsFilterAndPagination(t *testing.T) {
|
|
unixTime := time.Unix(0, 0)
|
|
firstSwapStartTime := unixTime.Add(10 * time.Minute)
|
|
secondSwapStartTime := unixTime.Add(20 * time.Minute)
|
|
thirdSwapStartTime := unixTime.Add(30 * time.Minute)
|
|
|
|
// Create a set of test swaps of various types which contain the minimal
|
|
// viable amount of info to successfully be run through marshallSwap.
|
|
swapInOrder0 := loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
State: loopdb.StateInitiated,
|
|
Cost: loopdb.SwapCost{},
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
InitiationTime: firstSwapStartTime,
|
|
},
|
|
LastUpdate: time.Now(),
|
|
SwapHash: lntypes.Hash{1},
|
|
SwapType: swap.TypeIn,
|
|
HtlcAddressP2WSH: testnetAddr,
|
|
HtlcAddressP2TR: testnetAddr,
|
|
}
|
|
|
|
swapOutOrder1 := loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
State: loopdb.StateInitiated,
|
|
Cost: loopdb.SwapCost{},
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
InitiationTime: secondSwapStartTime,
|
|
},
|
|
LastUpdate: time.Now(),
|
|
SwapHash: lntypes.Hash{2},
|
|
SwapType: swap.TypeOut,
|
|
HtlcAddressP2WSH: testnetAddr,
|
|
HtlcAddressP2TR: testnetAddr,
|
|
}
|
|
|
|
swapOutOrder2 := loop.SwapInfo{
|
|
SwapStateData: loopdb.SwapStateData{
|
|
State: loopdb.StateInitiated,
|
|
Cost: loopdb.SwapCost{},
|
|
},
|
|
SwapContract: loopdb.SwapContract{
|
|
InitiationTime: thirdSwapStartTime,
|
|
},
|
|
LastUpdate: time.Now(),
|
|
SwapHash: lntypes.Hash{3},
|
|
SwapType: swap.TypeOut,
|
|
HtlcAddressP2WSH: testnetAddr,
|
|
HtlcAddressP2TR: testnetAddr,
|
|
}
|
|
|
|
mockSwaps := []loop.SwapInfo{swapInOrder0, swapOutOrder1, swapOutOrder2}
|
|
|
|
tests := []struct {
|
|
name string
|
|
// Define the mock swaps that will be stored in the mock client.
|
|
mockSwaps []loop.SwapInfo
|
|
req *looprpc.ListSwapsRequest
|
|
// These hashes must be in the correct return order as the response.
|
|
expectedReturnedSwaps []lntypes.Hash
|
|
expectedNextStartTime int64
|
|
}{
|
|
{
|
|
name: "fetch with defaults",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with swaptype=loopin filter",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
SwapType: looprpc.ListSwapsFilter_LOOP_IN},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with swaptype=loopout filter",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
SwapType: looprpc.ListSwapsFilter_LOOP_OUT,
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with limit",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
MaxSwaps: 2,
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
},
|
|
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
|
|
},
|
|
{
|
|
name: "fetch with limit set to default",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
MaxSwaps: 0,
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with time filter #1",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: unixTime.Add(25 * time.Minute).UnixNano(),
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with time filter #2",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: unixTime.Add(5 * time.Minute).UnixNano(),
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with swaptype=loopout filter, time filter, limit set",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
SwapType: looprpc.ListSwapsFilter_LOOP_OUT,
|
|
StartTimestampNs: unixTime.Add(15 * time.Minute).UnixNano(),
|
|
},
|
|
MaxSwaps: 1,
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder1.SwapHash,
|
|
},
|
|
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
|
|
},
|
|
{
|
|
name: "fetch with time filter, limit set",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: unixTime.UnixNano(),
|
|
},
|
|
MaxSwaps: 2,
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
},
|
|
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
|
|
},
|
|
{
|
|
name: "fetch with time filter, limit set 2",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: unixTime.UnixNano(),
|
|
},
|
|
MaxSwaps: 3,
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapInOrder0.SwapHash,
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with time filter edge case 1",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: secondSwapStartTime.UnixNano(),
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with time filter edge case 2",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: secondSwapStartTime.UnixNano() + 1,
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
{
|
|
name: "fetch with time filter edge case 3",
|
|
mockSwaps: mockSwaps,
|
|
req: &looprpc.ListSwapsRequest{
|
|
ListSwapFilter: &looprpc.ListSwapsFilter{
|
|
StartTimestampNs: secondSwapStartTime.UnixNano() - 1,
|
|
},
|
|
},
|
|
expectedReturnedSwaps: []lntypes.Hash{
|
|
swapOutOrder1.SwapHash,
|
|
swapOutOrder2.SwapHash,
|
|
},
|
|
expectedNextStartTime: 0,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create the swap client server with our mock client.
|
|
server := &swapClientServer{
|
|
swaps: make(map[lntypes.Hash]loop.SwapInfo),
|
|
}
|
|
|
|
// Populate the server's swap cache with our mock swaps.
|
|
for _, swap := range test.mockSwaps {
|
|
server.swaps[swap.SwapHash] = swap
|
|
}
|
|
|
|
// Call the ListSwaps method.
|
|
resp, err := server.ListSwaps(context.Background(), test.req)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(
|
|
t,
|
|
resp.Swaps,
|
|
len(test.expectedReturnedSwaps),
|
|
"incorrect returned count",
|
|
)
|
|
|
|
// Check order of returned swaps is exactly as expected.
|
|
for idx, aswap := range resp.Swaps {
|
|
newhash, err := lntypes.MakeHash(aswap.GetIdBytes())
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t,
|
|
test.expectedReturnedSwaps[idx],
|
|
newhash,
|
|
"iteration order mismatch",
|
|
)
|
|
}
|
|
|
|
require.Equal(
|
|
t,
|
|
test.expectedNextStartTime,
|
|
resp.NextStartTime,
|
|
"incorrect next start time",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockAddressStore is a minimal in-memory store for address parameters.
|
|
type mockAddressStore struct {
|
|
params []*script.Parameters
|
|
}
|
|
|
|
func (s *mockAddressStore) CreateStaticAddress(_ context.Context,
|
|
p *script.Parameters) error {
|
|
|
|
s.params = append(s.params, p)
|
|
return nil
|
|
}
|
|
|
|
func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) (
|
|
*script.Parameters, error) {
|
|
|
|
if len(s.params) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return s.params[0], nil
|
|
}
|
|
|
|
func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) (
|
|
[]*script.Parameters, error) {
|
|
|
|
return s.params, nil
|
|
}
|
|
|
|
// mockDepositStore implements deposit.Store minimally for DepositsForOutpoints.
|
|
type mockDepositStore struct {
|
|
byOutpoint map[string]*deposit.Deposit
|
|
}
|
|
|
|
func (s *mockDepositStore) CreateDeposit(_ context.Context,
|
|
_ *deposit.Deposit) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *mockDepositStore) UpdateDeposit(_ context.Context,
|
|
_ *deposit.Deposit) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *mockDepositStore) GetDeposit(_ context.Context,
|
|
_ deposit.ID) (*deposit.Deposit, error) {
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
|
|
outpoint string) (*deposit.Deposit, error) {
|
|
|
|
if d, ok := s.byOutpoint[outpoint]; ok {
|
|
return d, nil
|
|
}
|
|
return nil, deposit.ErrDepositNotFound
|
|
}
|
|
|
|
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
|
|
error) {
|
|
|
|
deposits := make([]*deposit.Deposit, 0, len(s.byOutpoint))
|
|
for _, d := range s.byOutpoint {
|
|
deposits = append(deposits, d)
|
|
}
|
|
|
|
return deposits, nil
|
|
}
|
|
|
|
// listUnspentDepositManager backs ListUnspentDeposits tests without requiring
|
|
// the full deposit manager event loop.
|
|
type listUnspentDepositManager struct {
|
|
byOutpoint map[string]*deposit.Deposit
|
|
|
|
ensureDepositsFreshCalls int
|
|
onEnsureDepositsFresh func(*listUnspentDepositManager)
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) EnsureDepositsFresh(
|
|
context.Context) error {
|
|
|
|
m.ensureDepositsFreshCalls++
|
|
if m.onEnsureDepositsFresh != nil {
|
|
m.onEnsureDepositsFresh(m)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) GetActiveDepositsInState(
|
|
state fsm.StateType) ([]*deposit.Deposit, error) {
|
|
|
|
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
|
|
for _, d := range m.byOutpoint {
|
|
if !d.IsInState(state) {
|
|
continue
|
|
}
|
|
|
|
deposits = append(deposits, d)
|
|
}
|
|
|
|
return deposits, nil
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) DepositsForOutpoints(
|
|
_ context.Context, outpoints []string, ignoreUnknown bool) (
|
|
[]*deposit.Deposit, error) {
|
|
|
|
deposits := make([]*deposit.Deposit, 0, len(outpoints))
|
|
seen := make(map[string]struct{}, len(outpoints))
|
|
for i, outpoint := range outpoints {
|
|
if _, ok := seen[outpoint]; ok {
|
|
return nil, fmt.Errorf("duplicate outpoint %s "+
|
|
"at index %d", outpoint, i)
|
|
}
|
|
seen[outpoint] = struct{}{}
|
|
|
|
d, ok := m.byOutpoint[outpoint]
|
|
if !ok {
|
|
if ignoreUnknown {
|
|
continue
|
|
}
|
|
|
|
return nil, deposit.ErrDepositNotFound
|
|
}
|
|
|
|
deposits = append(deposits, d)
|
|
}
|
|
|
|
return deposits, nil
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) GetVisibleDeposits(
|
|
context.Context) ([]*deposit.Deposit, error) {
|
|
|
|
return m.allDeposits(), nil
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) GetAllDeposits(
|
|
context.Context) ([]*deposit.Deposit, error) {
|
|
|
|
return m.allDeposits(), nil
|
|
}
|
|
|
|
func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit {
|
|
deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint))
|
|
for _, d := range m.byOutpoint {
|
|
deposits = append(deposits, d)
|
|
}
|
|
|
|
return deposits
|
|
}
|
|
|
|
// TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits.
|
|
func TestListUnspentDeposits(t *testing.T) {
|
|
ctx := context.Background()
|
|
mock := mock_lnd.NewMockLnd()
|
|
|
|
// Prepare a single static address parameter set.
|
|
_, client := mock_lnd.CreateKey(1)
|
|
_, server := mock_lnd.CreateKey(2)
|
|
pkScript := []byte("pkscript")
|
|
addrParams := &script.Parameters{
|
|
ClientPubkey: client,
|
|
ServerPubkey: server,
|
|
Expiry: 10,
|
|
PkScript: pkScript,
|
|
}
|
|
|
|
addrStore := &mockAddressStore{params: []*script.Parameters{addrParams}}
|
|
|
|
// Build an address manager using our mock lnd and fake address store.
|
|
addrMgr, err := address.NewManager(&address.ManagerConfig{
|
|
Store: addrStore,
|
|
WalletKit: mock.WalletKit,
|
|
ChainParams: mock.ChainParams,
|
|
// ChainNotifier and AddressClient are not needed for this test.
|
|
}, 1)
|
|
require.NoError(t, err)
|
|
|
|
// Construct several UTXOs with different confirmation counts.
|
|
makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo {
|
|
return &lnwallet.Utxo{
|
|
AddressType: lnwallet.TaprootPubkey,
|
|
Value: btcutil.Amount(250_000 + int64(idx)),
|
|
Confirmations: confs,
|
|
PkScript: pkScript,
|
|
OutPoint: wire.OutPoint{
|
|
Hash: chainhash.Hash{byte(idx + 1)},
|
|
Index: idx,
|
|
},
|
|
}
|
|
}
|
|
|
|
utxoUnknown := makeUtxo(0, 0)
|
|
utxoDeposited := makeUtxo(1, 1)
|
|
utxoWithdrawn := makeUtxo(2, 2)
|
|
utxoLoopingIn := makeUtxo(3, 5)
|
|
utxoConfirmedUnknown := makeUtxo(4, 3)
|
|
|
|
// Helper to build the deposit manager with specific states.
|
|
buildDepositMgr := func(
|
|
states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager {
|
|
|
|
depMgr := &listUnspentDepositManager{
|
|
byOutpoint: make(map[string]*deposit.Deposit),
|
|
}
|
|
for op, state := range states {
|
|
d := &deposit.Deposit{OutPoint: op}
|
|
d.SetState(state)
|
|
depMgr.byOutpoint[op.String()] = d
|
|
}
|
|
|
|
return depMgr
|
|
}
|
|
|
|
// 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{
|
|
utxoUnknown, utxoDeposited, utxoWithdrawn,
|
|
utxoLoopingIn,
|
|
})
|
|
|
|
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
|
utxoDeposited.OutPoint: deposit.Deposited,
|
|
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
|
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
|
})
|
|
|
|
server := &swapClientServer{
|
|
staticAddressManager: addrMgr,
|
|
depositManager: depMgr,
|
|
}
|
|
|
|
resp, err := server.ListUnspentDeposits(
|
|
ctx, &looprpc.ListUnspentDepositsRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
|
|
|
// Expect the Deposited utxo only.
|
|
require.Len(t, resp.Utxos, 1)
|
|
got := map[string]struct{}{}
|
|
for _, u := range resp.Utxos {
|
|
got[u.Outpoint] = struct{}{}
|
|
// Confirm address string is non-empty and the
|
|
// same across utxos.
|
|
require.NotEmpty(t, u.StaticAddress)
|
|
}
|
|
_, ok := got[utxoDeposited.OutPoint.String()]
|
|
require.True(t, ok)
|
|
})
|
|
|
|
// 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{
|
|
utxoUnknown, utxoDeposited,
|
|
utxoWithdrawn, utxoLoopingIn,
|
|
})
|
|
|
|
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
|
|
utxoDeposited.OutPoint: deposit.Deposited,
|
|
utxoWithdrawn.OutPoint: deposit.Withdrawn,
|
|
utxoLoopingIn.OutPoint: deposit.LoopingIn,
|
|
})
|
|
|
|
server := &swapClientServer{
|
|
staticAddressManager: addrMgr,
|
|
depositManager: depMgr,
|
|
}
|
|
|
|
resp, err := server.ListUnspentDeposits(
|
|
ctx, &looprpc.ListUnspentDepositsRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
|
|
|
require.Len(t, resp.Utxos, 1)
|
|
got := map[string]struct{}{}
|
|
for _, u := range resp.Utxos {
|
|
got[u.Outpoint] = struct{}{}
|
|
}
|
|
_, ok := got[utxoDeposited.OutPoint.String()]
|
|
require.True(t, ok)
|
|
})
|
|
|
|
// A wallet-visible UTXO reconciled by EnsureDepositsFresh should be
|
|
// returned in the same ListUnspentDeposits call.
|
|
t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) {
|
|
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
|
|
|
|
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
|
|
depMgr.onEnsureDepositsFresh = func(
|
|
m *listUnspentDepositManager) {
|
|
|
|
d := &deposit.Deposit{
|
|
OutPoint: utxoConfirmedUnknown.OutPoint,
|
|
}
|
|
d.SetState(deposit.Deposited)
|
|
m.byOutpoint[d.OutPoint.String()] = d
|
|
}
|
|
|
|
server := &swapClientServer{
|
|
staticAddressManager: addrMgr,
|
|
depositManager: depMgr,
|
|
}
|
|
|
|
resp, err := server.ListUnspentDeposits(
|
|
ctx, &looprpc.ListUnspentDepositsRequest{},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
|
|
|
|
require.Len(t, resp.Utxos, 1)
|
|
require.Equal(
|
|
t, utxoConfirmedUnknown.OutPoint.String(),
|
|
resp.Utxos[0].Outpoint,
|
|
)
|
|
})
|
|
}
|