package loopd import ( "context" "fmt" "os" "testing" "time" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" mock_lnd "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) var ( testnetAddr, _ = btcutil.NewAddressScriptHash( []byte{123}, &chaincfg.TestNet3Params, ) mainnetAddr, _ = btcutil.NewAddressScriptHash( []byte{123}, &chaincfg.MainNetParams, ) nodepubkeyAddr, _ = btcutil.DecodeAddress( mock_lnd.NewMockLnd().NodePubkey, &chaincfg.MainNetParams, ) chanID1 = lnwire.NewShortChanIDFromInt(1) chanID2 = lnwire.NewShortChanIDFromInt(2) chanID3 = lnwire.NewShortChanIDFromInt(3) chanID4 = lnwire.NewShortChanIDFromInt(4) peer1 = route.Vertex{1} peer2 = route.Vertex{2} channel1 = lndclient.ChannelInfo{ Active: false, ChannelID: chanID1.ToUint64(), PubKeyBytes: peer1, LocalBalance: 10000, RemoteBalance: 0, Capacity: 10000, } channel2 = lndclient.ChannelInfo{ Active: true, ChannelID: chanID2.ToUint64(), PubKeyBytes: peer2, LocalBalance: 10000, RemoteBalance: 0, Capacity: 10000, } channel3 = lndclient.ChannelInfo{ Active: true, ChannelID: chanID3.ToUint64(), PubKeyBytes: peer2, LocalBalance: 10000, RemoteBalance: 0, Capacity: 10000, } channel4 = lndclient.ChannelInfo{ Active: true, ChannelID: chanID4.ToUint64(), PubKeyBytes: peer2, LocalBalance: 1000, RemoteBalance: 0, Capacity: 1000, } ) // TestValidateConfTarget tests all failure and success cases for our conf // target validation function, including the case where we replace a zero // target with the default provided. func TestValidateConfTarget(t *testing.T) { const ( // Various input confirmation values for tests. zeroConf int32 = 0 oneConf int32 = 1 twoConf int32 = 2 fiveConf int32 = 5 // defaultConf is the default confirmation target we use for // all tests. defaultConf = 6 ) tests := []struct { name string confTarget int32 expectedTarget int32 expectErr bool }{ { name: "zero conf, get default", confTarget: zeroConf, expectedTarget: defaultConf, expectErr: false, }, { name: "one conf, get error", confTarget: oneConf, expectErr: true, }, { name: "two conf, ok", confTarget: twoConf, expectedTarget: twoConf, expectErr: false, }, { name: "five conf, ok", confTarget: fiveConf, expectedTarget: fiveConf, expectErr: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { target, err := validateConfTarget( test.confTarget, defaultConf, ) if test.expectErr { require.Error(t, err) } else { require.NoError(t, err) } require.Equal(t, test.expectedTarget, target) }) } } // TestValidateLoopInRequest tests validation of loop in requests. func TestValidateLoopInRequest(t *testing.T) { tests := []struct { name string amount int64 numDeposits uint32 external bool confTarget int32 autoSelectDeposits bool expectErr bool expectedTarget int32 }{ { name: "external and htlc conf set", amount: 100_000, external: true, confTarget: 1, expectErr: true, expectedTarget: 0, }, { name: "external and no conf", amount: 100_000, external: true, confTarget: 0, expectErr: false, expectedTarget: 0, }, { name: "not external, zero conf", amount: 100_000, external: false, confTarget: 0, expectErr: false, expectedTarget: loop.DefaultHtlcConfTarget, }, { name: "not external, bad conf", amount: 100_000, external: false, confTarget: 1, expectErr: true, expectedTarget: 0, }, { name: "not external, ok conf", amount: 100_000, external: false, confTarget: 5, expectErr: false, expectedTarget: 5, }, { name: "not external, amount no deposit", amount: 100_000, numDeposits: 0, external: false, expectErr: false, expectedTarget: loop.DefaultHtlcConfTarget, }, { name: "not external, deposit no amount", amount: 100_000, numDeposits: 1, external: false, expectErr: false, }, { name: "not external, deposit fractional amount", amount: 100_000, numDeposits: 1, external: false, expectErr: false, }, { name: "amount with deposit coin select", amount: 100_000, autoSelectDeposits: true, external: false, expectErr: false, }, { name: "amount with deposit coin select", numDeposits: 1, autoSelectDeposits: true, external: false, expectErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { external := test.external conf, err := validateLoopInRequest( test.confTarget, external, test.numDeposits, btcutil.Amount(test.amount), test.autoSelectDeposits, ) if test.expectErr { require.Error(t, err) } else { require.NoError(t, err) } require.Equal(t, test.expectedTarget, conf) }) } } // TestStaticAddressLoopInRejectsReservedLabel verifies that external static // loop-in requests still reject reserved autoloop labels at the RPC boundary. func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) { logger := btclog.NewSLogger( btclog.NewDefaultHandler(os.Stdout), ) setLogger(logger.SubSystem(Subsystem)) server := &swapClientServer{} _, err := server.StaticAddressLoopIn( t.Context(), &looprpc.StaticAddressLoopInRequest{ Label: labels.AutoloopLabel(swap.TypeIn), }, ) require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) } // TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental verifies that // users must restart loopd with --experimental before enabling static-address // autoloop. func TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental( t *testing.T) { server := &swapClientServer{ config: &Config{}, } _, err := server.SetLiquidityParams( t.Context(), &looprpc.SetLiquidityParamsRequest{ Parameters: &looprpc.LiquidityParameters{ LoopInSource: looprpc. LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, }, }, ) require.Error(t, err) require.Equal(t, codes.FailedPrecondition, status.Code(err)) require.ErrorContains(t, err, "--experimental") } // TestStaticAddressLoopInTimestamp verifies that zero timestamps are omitted // from static loop-in responses instead of passing a zero time to UnixNano. func TestStaticAddressLoopInTimestamp(t *testing.T) { require.Zero(t, staticAddressLoopInTimestamp(time.Time{})) timestamp := time.Unix(1_234, 567).UTC() require.Equal( t, timestamp.UnixNano(), staticAddressLoopInTimestamp(timestamp), ) } // TestStaticAddressLoopInSwapServerCost verifies that static loop-in server // costs are only reported once the invoice payment was received. Timeout path // costs are not persisted today, so they are intentionally not estimated here. func TestStaticAddressLoopInSwapServerCost(t *testing.T) { const quoteFee = btcutil.Amount(1_234) tests := []struct { name string state fsm.StateType wantServer int64 }{ { name: "pending before payment", state: loopin.SignHtlcTx, }, { name: "payment received", state: loopin.PaymentReceived, wantServer: int64(quoteFee), }, { name: "succeeded", state: loopin.Succeeded, wantServer: int64(quoteFee), }, { name: "succeeded transition failed", state: loopin.SucceededTransitioningFailed, wantServer: int64(quoteFee), }, { name: "timeout swept", state: loopin.HtlcTimeoutSwept, }, { name: "failed", state: loopin.Failed, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { swap := &loopin.StaticAddressLoopIn{ QuotedSwapFee: quoteFee, } swap.SetState(test.state) costServer := staticAddressLoopInSwapServerCost(swap) require.Equal(t, test.wantServer, costServer) }) } } // TestListStaticAddressSwapsPopulatesTimingAndCosts verifies that the RPC // response maps stored static loop-in timing fields and cost fields. func TestListStaticAddressSwapsPopulatesTimingAndCosts(t *testing.T) { ctx := t.Context() lnd := mock_lnd.NewMockLnd() const ( paymentRequestAmount = btcutil.Amount(50_000) quotedSwapFee = btcutil.Amount(1_234) depositValue = btcutil.Amount(51_234) depositConfHeight = int64(590) staticAddressExpiry = uint32(25) ) _, swapInvoice, err := lnd.Client.AddInvoice( ctx, &invoicesrpc.AddInvoiceData{ Value: lnwire.NewMSatFromSatoshis(paymentRequestAmount), }, ) require.NoError(t, err) swapHash := lntypes.Hash{1, 2, 3} depositID := deposit.ID{4, 5, 6} depositOutpoint := wire.OutPoint{ Hash: chainhash.Hash{7, 8, 9}, Index: 1, } testDeposit := &deposit.Deposit{ ID: depositID, OutPoint: depositOutpoint, Value: depositValue, ConfirmationHeight: depositConfHeight, SwapHash: &swapHash, } testDeposit.SetState(deposit.LoopedIn) initiationTime := time.Unix(1_234, 567).UTC() lastUpdateTime := time.Unix(2_345, 678).UTC() staticLoopIn := &loopin.StaticAddressLoopIn{ SwapHash: swapHash, SwapInvoice: swapInvoice, InitiationTime: initiationTime, LastUpdateTime: lastUpdateTime, QuotedSwapFee: quotedSwapFee, DepositOutpoints: []string{depositOutpoint.String()}, Deposits: []*deposit.Deposit{testDeposit}, } staticLoopIn.SetState(loopin.Succeeded) depositStore := &mockDepositStore{ byOutpoint: map[string]*deposit.Deposit{ depositOutpoint.String(): testDeposit, }, } depositMgr := deposit.NewManager(&deposit.ManagerConfig{ Store: depositStore, }) staticLoopInMgr, err := loopin.NewManager(&loopin.Config{ Store: &mockStaticAddressLoopInStore{ swaps: []*loopin.StaticAddressLoopIn{staticLoopIn}, }, DepositManager: depositMgr, }, 1) require.NoError(t, err) _, clientPubkey := mock_lnd.CreateKey(1) _, serverPubkey := mock_lnd.CreateKey(2) addrStore := &mockAddressStore{ params: []*script.Parameters{{ ClientPubkey: clientPubkey, ServerPubkey: serverPubkey, Expiry: staticAddressExpiry, PkScript: []byte("pkscript"), }}, } addrMgr, err := address.NewManager(&address.ManagerConfig{ Store: addrStore, WalletKit: lnd.WalletKit, ChainParams: lnd.ChainParams, }, 1) require.NoError(t, err) server := &swapClientServer{ network: lndclient.NetworkTestnet, lnd: &lnd.LndServices, staticAddressManager: addrMgr, depositManager: depositMgr, staticLoopInManager: staticLoopInMgr, } resp, err := server.ListStaticAddressSwaps( ctx, &looprpc.ListStaticAddressSwapsRequest{}, ) require.NoError(t, err) require.Len(t, resp.Swaps, 1) swap := resp.Swaps[0] require.Equal(t, swapHash[:], swap.SwapHash) require.Equal(t, []string{depositOutpoint.String()}, swap.DepositOutpoints) require.Equal( t, looprpc.StaticAddressLoopInSwapState_SUCCEEDED, swap.State, ) require.Equal(t, int64(depositValue), swap.SwapAmountSatoshis) require.Equal( t, int64(paymentRequestAmount), swap.PaymentRequestAmountSatoshis, ) require.Equal(t, initiationTime.UnixNano(), swap.InitiationTime) require.Equal(t, lastUpdateTime.UnixNano(), swap.LastUpdateTime) require.Equal(t, int64(quotedSwapFee), swap.CostServer) require.Zero(t, swap.CostOnchain) require.Zero(t, swap.CostOffchain) require.Len(t, swap.Deposits, 1) rpcDeposit := swap.Deposits[0] require.Equal(t, depositID[:], rpcDeposit.Id) require.Equal(t, depositOutpoint.String(), rpcDeposit.Outpoint) require.Equal(t, int64(depositValue), rpcDeposit.Value) require.Equal(t, depositConfHeight, rpcDeposit.ConfirmationHeight) require.Equal(t, swapHash[:], rpcDeposit.SwapHash) require.Equal(t, looprpc.DepositState_LOOPED_IN, rpcDeposit.State) require.Equal( t, depositConfHeight+int64(staticAddressExpiry)-600, rpcDeposit.BlocksUntilExpiry, ) } // TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH protects the RPC // mapping invariant that static loop-ins expose their static type, static // state, and P2WSH HTLC address without leaking a taproot HTLC address. func TestStaticAddressLoopInMarshallUsesStaticTypeAndP2WSH(t *testing.T) { server := &swapClientServer{} loopSwap := &loop.SwapInfo{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateInitiated, }, SwapContract: loopdb.SwapContract{ InitiationTime: time.Now(), }, LastUpdate: time.Now(), SwapHash: lntypes.Hash{1}, SwapType: swap.TypeStaticAddressLoopIn, StaticAddressLoopInState: loopin.SignHtlcTx, HtlcAddressP2WSH: testnetAddr, } rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) require.NoError(t, err) require.Equal(t, looprpc.SwapType_STATIC_LOOP_IN, rpcSwap.Type) require.Equal( t, looprpc.StaticAddressLoopInSwapState_SIGN_HTLC_TX, rpcSwap.GetStaticLoopInState(), ) require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) require.Equal(t, testnetAddr.EncodeAddress(), rpcSwap.HtlcAddressP2Wsh) require.Empty(t, rpcSwap.HtlcAddressP2Tr) } // TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault asserts that // static loop-in failures keep default legacy fields while preserving the // precise static state. func TestStaticAddressLoopInMarshallFailuresLeaveLegacyFieldsDefault( t *testing.T) { tests := []struct { name string state fsm.StateType wantStaticState looprpc.StaticAddressLoopInSwapState }{ { name: "failed", state: loopin.Failed, wantStaticState: looprpc. StaticAddressLoopInSwapState_FAILED_STATIC_ADDRESS_SWAP, }, { name: "succeeded transitioning failed", state: loopin.SucceededTransitioningFailed, wantStaticState: looprpc. StaticAddressLoopInSwapState_SUCCEEDED_TRANSITIONING_FAILED, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server, staticLoopIn := newGenericStaticLoopInServer(t) staticLoopIn.SetState(test.state) loopSwap, err := server.staticAddressLoopInSwapInfo( t.Context(), staticLoopIn, ) require.NoError(t, err) rpcSwap, err := server.marshallSwap(t.Context(), loopSwap) require.NoError(t, err) require.Equal(t, looprpc.SwapState_INITIATED, rpcSwap.State) require.Equal( t, looprpc.FailureReason_FAILURE_REASON_NONE, rpcSwap.FailureReason, ) require.Equal( t, test.wantStaticState, rpcSwap.GetStaticLoopInState(), ) }) } } // TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress protects the // fail-closed HTLC-address invariant for static loop-ins missing the P2WSH // address required by the client-facing RPC representation. func TestStaticAddressLoopInMarshallRejectsMissingHtlcAddress(t *testing.T) { _, taprootAddress := newTestStaticAddressParams(t) server := &swapClientServer{} loopSwap := &loop.SwapInfo{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateInitiated, }, SwapContract: loopdb.SwapContract{ InitiationTime: time.Now(), }, LastUpdate: time.Now(), SwapHash: lntypes.Hash{1}, SwapType: swap.TypeStaticAddressLoopIn, StaticAddressLoopInState: loopin.SignHtlcTx, HtlcAddressP2TR: taprootAddress, } _, err := server.marshallSwap(t.Context(), loopSwap) require.ErrorContains(t, err, "missing static address loop-in P2WSH HTLC address") } // TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing protects the // HTLC-address construction invariant that missing cooperative keys must not // produce monitorable swap info. func TestStaticAddressLoopInSwapInfoFailsClosedWhenHtlcKeysMissing(t *testing.T) { server, staticLoopIn := newGenericStaticLoopInServer(t) staticLoopIn.ClientPubkey = nil _, err := server.staticAddressLoopInSwapInfo(t.Context(), staticLoopIn) require.ErrorContains( t, err, "missing static address loop-in client HTLC key", ) } 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 } // mockStaticAddressLoopInStore is a minimal in-memory loop-in store for RPC // response mapping tests. type mockStaticAddressLoopInStore struct { swaps []*loopin.StaticAddressLoopIn } // 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( _ context.Context, _ []fsm.StateType) ([]*loopin.StaticAddressLoopIn, error) { 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: "can split between channels", chain: chaincfg.MainNetParams, destAddr: mainnetAddr, label: "label ok", confTarget: 2, channels: []lndclient.ChannelInfo{ channel2, channel4, }, amount: 11000, maxParts: 16, err: nil, expectedTarget: 2, }, { name: "can't split between channels", chain: chaincfg.MainNetParams, destAddr: mainnetAddr, label: "label ok", confTarget: 2, channels: []lndclient.ChannelInfo{ channel2, channel4, }, amount: 11000, maxParts: 5, err: errBalanceTooLow, expectedTarget: 0, }, { name: "node pubkey as dest addr", chain: chaincfg.MainNetParams, destAddr: nodepubkeyAddr, err: errInvalidAddress, expectedTarget: 0, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() ctx := context.Background() lnd := mock_lnd.NewMockLnd() lnd.Channels = test.channels req := &looprpc.LoopOutRequest{ Amt: test.amount, MaxSwapRoutingFee: test.maxRoutingFee, OutgoingChanSet: test.outgoingChanSet, Label: test.label, SweepConfTarget: test.confTarget, } logger := btclog.NewSLogger( btclog.NewDefaultHandler(os.Stdout), ) setLogger(logger.SubSystem(Subsystem)) conf, err := validateLoopOutRequest( ctx, lnd.Client, &test.chain, req, test.destAddr, test.maxParts, ) require.ErrorIs(t, err, test.err) require.Equal(t, test.expectedTarget, conf) }) } } // TestHasBandwidth tests that the hasBandwidth function correctly simulates // the MPP logic used by LND. func TestHasBandwidth(t *testing.T) { tests := []struct { name string channels []lndclient.ChannelInfo maxParts int amt btcutil.Amount expectedRes bool expectedShards int }{ { name: "can route due to high number of parts", channels: []lndclient.ChannelInfo{ { LocalBalance: 100, }, { LocalBalance: 10, }, }, maxParts: 11, amt: 110, expectedRes: true, expectedShards: 8, }, { name: "can't route due to low number of parts", channels: []lndclient.ChannelInfo{ { LocalBalance: 100, }, { LocalBalance: 10, }, }, maxParts: 5, amt: 110, expectedRes: false, }, { name: "can route", channels: []lndclient.ChannelInfo{ { LocalBalance: 1000, }, { LocalBalance: 1000, }, }, maxParts: 5, amt: 2000, expectedRes: true, expectedShards: 2, }, { name: "can route", channels: []lndclient.ChannelInfo{ { LocalBalance: 100, }, { LocalBalance: 100, }, { LocalBalance: 100, }, }, maxParts: 10, amt: 300, expectedRes: true, expectedShards: 10, }, { name: "can't route due to empty channel set", maxParts: 10, amt: 300, expectedRes: false, expectedShards: 0, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() res, shards := hasBandwidth(test.channels, test.amt, test.maxParts) require.Equal(t, test.expectedRes, res) require.Equal(t, test.expectedShards, shards) }) } } // TestListSwapsFilterAndPagination tests the filtering and // paging of the ListSwaps command. func TestListSwapsFilterAndPagination(t *testing.T) { unixTime := time.Unix(0, 0) firstSwapStartTime := unixTime.Add(10 * time.Minute) secondSwapStartTime := unixTime.Add(20 * time.Minute) thirdSwapStartTime := unixTime.Add(30 * time.Minute) // Create a set of test swaps of various types which contain the minimal // viable amount of info to successfully be run through marshallSwap. swapInOrder0 := loop.SwapInfo{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateInitiated, Cost: loopdb.SwapCost{}, }, SwapContract: loopdb.SwapContract{ InitiationTime: firstSwapStartTime, }, LastUpdate: time.Now(), SwapHash: lntypes.Hash{1}, SwapType: swap.TypeIn, HtlcAddressP2WSH: testnetAddr, HtlcAddressP2TR: testnetAddr, } swapOutOrder1 := loop.SwapInfo{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateInitiated, Cost: loopdb.SwapCost{}, }, SwapContract: loopdb.SwapContract{ InitiationTime: secondSwapStartTime, }, LastUpdate: time.Now(), SwapHash: lntypes.Hash{2}, SwapType: swap.TypeOut, HtlcAddressP2WSH: testnetAddr, HtlcAddressP2TR: testnetAddr, } swapOutOrder2 := loop.SwapInfo{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateInitiated, Cost: loopdb.SwapCost{}, }, SwapContract: loopdb.SwapContract{ InitiationTime: thirdSwapStartTime, }, LastUpdate: time.Now(), SwapHash: lntypes.Hash{3}, SwapType: swap.TypeOut, HtlcAddressP2WSH: testnetAddr, HtlcAddressP2TR: testnetAddr, } mockSwaps := []loop.SwapInfo{swapInOrder0, swapOutOrder1, swapOutOrder2} tests := []struct { name string // Define the mock swaps that will be stored in the mock client. mockSwaps []loop.SwapInfo req *looprpc.ListSwapsRequest // These hashes must be in the correct return order as the response. expectedReturnedSwaps []lntypes.Hash expectedNextStartTime int64 }{ { name: "fetch with defaults", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{}, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with swaptype=loopin filter", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ SwapType: looprpc.ListSwapsFilter_LOOP_IN}, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with swaptype=loopout filter", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ SwapType: looprpc.ListSwapsFilter_LOOP_OUT, }, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with limit", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ MaxSwaps: 2, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, }, expectedNextStartTime: secondSwapStartTime.UnixNano() + 1, }, { name: "fetch with limit set to default", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ MaxSwaps: 0, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with time filter #1", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: unixTime.Add(25 * time.Minute).UnixNano(), }, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with time filter #2", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: unixTime.Add(5 * time.Minute).UnixNano(), }, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with swaptype=loopout filter, time filter, limit set", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ SwapType: looprpc.ListSwapsFilter_LOOP_OUT, StartTimestampNs: unixTime.Add(15 * time.Minute).UnixNano(), }, MaxSwaps: 1, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder1.SwapHash, }, expectedNextStartTime: secondSwapStartTime.UnixNano() + 1, }, { name: "fetch with time filter, limit set", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: unixTime.UnixNano(), }, MaxSwaps: 2, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, }, expectedNextStartTime: secondSwapStartTime.UnixNano() + 1, }, { name: "fetch with time filter, limit set 2", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: unixTime.UnixNano(), }, MaxSwaps: 3, }, expectedReturnedSwaps: []lntypes.Hash{ swapInOrder0.SwapHash, swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with time filter edge case 1", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: secondSwapStartTime.UnixNano(), }, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with time filter edge case 2", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: secondSwapStartTime.UnixNano() + 1, }, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, { name: "fetch with time filter edge case 3", mockSwaps: mockSwaps, req: &looprpc.ListSwapsRequest{ ListSwapFilter: &looprpc.ListSwapsFilter{ StartTimestampNs: secondSwapStartTime.UnixNano() - 1, }, }, expectedReturnedSwaps: []lntypes.Hash{ swapOutOrder1.SwapHash, swapOutOrder2.SwapHash, }, expectedNextStartTime: 0, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() // Create the swap client server with our mock client. server := &swapClientServer{ swaps: make(map[lntypes.Hash]loop.SwapInfo), } // Populate the server's swap cache with our mock swaps. for _, swap := range test.mockSwaps { server.swaps[swap.SwapHash] = swap } // Call the ListSwaps method. resp, err := server.ListSwaps(context.Background(), test.req) require.NoError(t, err) require.Len( t, resp.Swaps, len(test.expectedReturnedSwaps), "incorrect returned count", ) // Check order of returned swaps is exactly as expected. for idx, aswap := range resp.Swaps { newhash, err := lntypes.MakeHash(aswap.GetIdBytes()) require.NoError(t, err) require.Equal( t, test.expectedReturnedSwaps[idx], newhash, "iteration order mismatch", ) } require.Equal( t, test.expectedNextStartTime, resp.NextStartTime, "incorrect next start time", ) }) } } // mockAddressStore is a minimal in-memory store for address parameters. type mockAddressStore struct { params []*script.Parameters } func (s *mockAddressStore) CreateStaticAddress(_ context.Context, p *script.Parameters) error { s.params = append(s.params, p) return nil } func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) ( *script.Parameters, error) { if len(s.params) == 0 { return nil, nil } return s.params[0], nil } func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) ( []*script.Parameters, error) { return s.params, nil } // mockDepositStore implements deposit.Store minimally for DepositsForOutpoints. type mockDepositStore struct { byOutpoint map[string]*deposit.Deposit } func (s *mockDepositStore) CreateDeposit(_ context.Context, _ *deposit.Deposit) error { return nil } func (s *mockDepositStore) UpdateDeposit(_ context.Context, _ *deposit.Deposit) error { return nil } func (s *mockDepositStore) GetDeposit(_ context.Context, _ deposit.ID) (*deposit.Deposit, error) { return nil, nil } func (s *mockDepositStore) DepositForOutpoint(_ context.Context, outpoint string) (*deposit.Deposit, error) { if d, ok := s.byOutpoint[outpoint]; ok { return d, nil } return nil, deposit.ErrDepositNotFound } func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit, error) { deposits := make([]*deposit.Deposit, 0, len(s.byOutpoint)) for _, d := range s.byOutpoint { deposits = append(deposits, d) } return deposits, nil } // listUnspentDepositManager backs ListUnspentDeposits tests without requiring // the full deposit manager event loop. type listUnspentDepositManager struct { byOutpoint map[string]*deposit.Deposit ensureDepositsFreshCalls int onEnsureDepositsFresh func(*listUnspentDepositManager) } func (m *listUnspentDepositManager) EnsureDepositsFresh( context.Context) error { m.ensureDepositsFreshCalls++ if m.onEnsureDepositsFresh != nil { m.onEnsureDepositsFresh(m) } return nil } func (m *listUnspentDepositManager) GetActiveDepositsInState( state fsm.StateType) ([]*deposit.Deposit, error) { deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint)) for _, d := range m.byOutpoint { if !d.IsInState(state) { continue } deposits = append(deposits, d) } return deposits, nil } func (m *listUnspentDepositManager) DepositsForOutpoints( _ context.Context, outpoints []string, ignoreUnknown bool) ( []*deposit.Deposit, error) { deposits := make([]*deposit.Deposit, 0, len(outpoints)) seen := make(map[string]struct{}, len(outpoints)) for i, outpoint := range outpoints { if _, ok := seen[outpoint]; ok { return nil, fmt.Errorf("duplicate outpoint %s "+ "at index %d", outpoint, i) } seen[outpoint] = struct{}{} d, ok := m.byOutpoint[outpoint] if !ok { if ignoreUnknown { continue } return nil, deposit.ErrDepositNotFound } deposits = append(deposits, d) } return deposits, nil } func (m *listUnspentDepositManager) GetVisibleDeposits( context.Context) ([]*deposit.Deposit, error) { return m.allDeposits(), nil } func (m *listUnspentDepositManager) GetAllDeposits( context.Context) ([]*deposit.Deposit, error) { return m.allDeposits(), nil } func (m *listUnspentDepositManager) allDeposits() []*deposit.Deposit { deposits := make([]*deposit.Deposit, 0, len(m.byOutpoint)) for _, d := range m.byOutpoint { deposits = append(deposits, d) } return deposits } // TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits. func TestListUnspentDeposits(t *testing.T) { ctx := context.Background() mock := mock_lnd.NewMockLnd() // Prepare a single static address parameter set. _, client := mock_lnd.CreateKey(1) _, server := mock_lnd.CreateKey(2) pkScript := []byte("pkscript") addrParams := &script.Parameters{ ClientPubkey: client, ServerPubkey: server, Expiry: 10, PkScript: pkScript, } addrStore := &mockAddressStore{params: []*script.Parameters{addrParams}} // Build an address manager using our mock lnd and fake address store. addrMgr, err := address.NewManager(&address.ManagerConfig{ Store: addrStore, WalletKit: mock.WalletKit, ChainParams: mock.ChainParams, // ChainNotifier and AddressClient are not needed for this test. }, 1) require.NoError(t, err) // Construct several UTXOs with different confirmation counts. makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo { return &lnwallet.Utxo{ AddressType: lnwallet.TaprootPubkey, Value: btcutil.Amount(250_000 + int64(idx)), Confirmations: confs, PkScript: pkScript, OutPoint: wire.OutPoint{ Hash: chainhash.Hash{byte(idx + 1)}, Index: idx, }, } } utxoUnknown := makeUtxo(0, 0) utxoDeposited := makeUtxo(1, 1) utxoWithdrawn := makeUtxo(2, 2) utxoLoopingIn := makeUtxo(3, 5) utxoConfirmedUnknown := makeUtxo(4, 3) // Helper to build the deposit manager with specific states. buildDepositMgr := func( states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager { depMgr := &listUnspentDepositManager{ byOutpoint: make(map[string]*deposit.Deposit), } for op, state := range states { d := &deposit.Deposit{OutPoint: op} d.SetState(state) depMgr.byOutpoint[op.String()] = d } return depMgr } // Only known Deposited records are available. Unknown deposits and // known non-Deposited states are excluded. t.Run("only known Deposited included", func(t *testing.T) { mock.SetListUnspent([]*lnwallet.Utxo{ utxoUnknown, utxoDeposited, utxoWithdrawn, utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ utxoDeposited.OutPoint: deposit.Deposited, utxoWithdrawn.OutPoint: deposit.Withdrawn, utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ staticAddressManager: addrMgr, depositManager: depMgr, } resp, err := server.ListUnspentDeposits( ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) // Expect the Deposited utxo only. require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} // Confirm address string is non-empty and the // same across utxos. require.NotEmpty(t, u.StaticAddress) } _, ok := got[utxoDeposited.OutPoint.String()] require.True(t, ok) }) // Confirmation depth no longer changes availability; state does. t.Run("availability ignores conf depth once deposit state is known", func(t *testing.T) { mock.SetListUnspent( []*lnwallet.Utxo{ utxoUnknown, utxoDeposited, utxoWithdrawn, utxoLoopingIn, }) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ utxoDeposited.OutPoint: deposit.Deposited, utxoWithdrawn.OutPoint: deposit.Withdrawn, utxoLoopingIn.OutPoint: deposit.LoopingIn, }) server := &swapClientServer{ staticAddressManager: addrMgr, depositManager: depMgr, } resp, err := server.ListUnspentDeposits( ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) require.Len(t, resp.Utxos, 1) got := map[string]struct{}{} for _, u := range resp.Utxos { got[u.Outpoint] = struct{}{} } _, ok := got[utxoDeposited.OutPoint.String()] require.True(t, ok) }) // A wallet-visible UTXO reconciled by EnsureDepositsFresh should be // returned in the same ListUnspentDeposits call. t.Run("freshly reconciled wallet utxo is included", func(t *testing.T) { mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown}) depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) depMgr.onEnsureDepositsFresh = func( m *listUnspentDepositManager) { d := &deposit.Deposit{ OutPoint: utxoConfirmedUnknown.OutPoint, } d.SetState(deposit.Deposited) m.byOutpoint[d.OutPoint.String()] = d } server := &swapClientServer{ staticAddressManager: addrMgr, depositManager: depMgr, } resp, err := server.ListUnspentDeposits( ctx, &looprpc.ListUnspentDepositsRequest{}, ) require.NoError(t, err) require.Equal(t, 1, depMgr.ensureDepositsFreshCalls) require.Len(t, resp.Utxos, 1) require.Equal( t, utxoConfirmedUnknown.OutPoint.String(), resp.Utxos[0].Outpoint, ) }) }