loop/loopd/swapclient_server_test.go
Slyghtning f3b3b1a695
staticaddr: expose addresses in deposit listings
Include the owning static address in every deposit RPC response and CLI
listing. Users can distinguish deposits created by different receive and
change addresses without reconstructing scripts externally.

Update generated RPC artifacts and command replay fixtures for the new
field.
2026-08-12 11:00:29 +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,
)
})
}