loop/loopd/swapclient_server_test.go
2026-08-12 11:47:51 +02:00

2241 lines
60 KiB
Go

package loopd
import (
"bytes"
"context"
"database/sql"
"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)
_, clientPubkey := mock_lnd.CreateKey(1)
_, serverPubkey := mock_lnd.CreateKey(2)
staticAddressParams := &script.Parameters{
ID: 1,
ClientPubkey: clientPubkey,
ServerPubkey: serverPubkey,
Expiry: staticAddressExpiry,
PkScript: []byte("pkscript"),
}
testDeposit.AddressParams = staticAddressParams
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)
addrStore := &mockAddressStore{
params: []*script.Parameters{staticAddressParams},
}
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 {
if p.ID == 0 {
p.ID = int32(len(s.params) + 1)
}
s.params = append(s.params, p)
return nil
}
func (s *mockAddressStore) GetStaticAddressID(_ context.Context,
pkScript []byte) (int32, error) {
for _, p := range s.params {
if bytes.Equal(p.PkScript, pkScript) {
return p.ID, nil
}
}
return 0, sql.ErrNoRows
}
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
}
func (s *mockAddressStore) GetLegacyParameters(_ context.Context) (
*address.Parameters, error) {
if len(s.params) == 0 {
return nil, sql.ErrNoRows
}
return s.params[0], 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)
staticAddress, err := script.NewStaticAddress(
input.MuSig2Version100RC2, 10, client, server,
)
require.NoError(t, err)
pkScript, err := staticAddress.StaticAddressScript()
require.NoError(t, err)
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)
_, err = addrMgr.EnsureStaticAddressSeed(ctx)
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,
)
})
}