From d3a2badc8b962e78d83578c46d0ef5fa69cf91ff Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 22 Aug 2025 14:43:42 +0200 Subject: [PATCH 1/2] loopd: filter unavailable deposits in ListUnspentDeposits ListUnspentRaw returns the unspent wallet view of the backing lnd wallet. It might be that deposits show up there that are actually not spendable because they already have been used but not yet spent by the server. We filter out such deposits in ListUnspentDeposits. --- loopd/swapclient_server.go | 58 +++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 782e9a745..1bf3da15c 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1546,9 +1546,65 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context, return nil, err } - // Prepare the list response. + // ListUnspentRaw returns the unspent wallet view of the backing lnd + // wallet. It might be that deposits show up there that are actually + // not spendable because they already have been used but not yet spent + // by the server. We filter out such deposits here. + var ( + outpoints []string + isUnspent = make(map[wire.OutPoint]struct{}) + ) + + // Keep track of confirmed outpoints that we need to check against our + // database. + confirmedToCheck := make(map[wire.OutPoint]struct{}) + + for _, utxo := range utxos { + if utxo.Confirmations < deposit.MinConfs { + // Unconfirmed deposits are always available. + isUnspent[utxo.OutPoint] = struct{}{} + } else { + // Confirmed deposits need to be checked. + outpoints = append(outpoints, utxo.OutPoint.String()) + confirmedToCheck[utxo.OutPoint] = struct{}{} + } + } + + // Check the spent status of the deposits by looking at their states. + deposits, err := s.depositManager.DepositsForOutpoints(ctx, outpoints) + if err != nil { + return nil, err + } + for _, d := range deposits { + // A nil deposit means we don't have a record for it. We'll + // handle this case after the loop. + if d == nil { + continue + } + + // If the deposit is in the "Deposited" state, it's available. + if d.IsInState(deposit.Deposited) { + isUnspent[d.OutPoint] = struct{}{} + } + + // We have a record for this deposit, so we no longer need to + // check it. + delete(confirmedToCheck, d.OutPoint) + } + + // Any remaining outpoints in confirmedToCheck are ones that lnd knows + // about but we don't. These are new, unspent deposits. + for op := range confirmedToCheck { + isUnspent[op] = struct{}{} + } + + // Prepare the list of unspent deposits for the rpc response. var respUtxos []*looprpc.Utxo for _, u := range utxos { + if _, ok := isUnspent[u.OutPoint]; !ok { + continue + } + utxo := &looprpc.Utxo{ StaticAddress: staticAddress.String(), AmountSat: int64(u.Value), From a862f8ba606080a4d3367afa29694e8e9c42f270 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Fri, 22 Aug 2025 14:44:04 +0200 Subject: [PATCH 2/2] test: unit test ListUnspentDeposits --- loopd/swapclient_server_test.go | 236 ++++++++++++++++++++++++++++++++ test/lnd_services_mock.go | 6 + test/walletkit_mock.go | 10 +- 3 files changed, 251 insertions(+), 1 deletion(-) diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f104fc856..65a61a32d 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -8,15 +8,21 @@ import ( "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/loopdb" "github.com/lightninglabs/loop/looprpc" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swap" mock_lnd "github.com/lightninglabs/loop/test" "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" @@ -891,3 +897,233 @@ func TestListSwapsFilterAndPagination(t *testing.T) { }) } } + +// mockAddressStore is a minimal in-memory store for address parameters. +type mockAddressStore struct { + params []*address.Parameters +} + +func (s *mockAddressStore) CreateStaticAddress(_ context.Context, + p *address.Parameters) error { + + s.params = append(s.params, p) + return nil +} + +func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) ( + *address.Parameters, error) { + + if len(s.params) == 0 { + return nil, nil + } + + return s.params[0], nil +} + +func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) ( + []*address.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, nil +} +func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit, + error) { + + return nil, nil +} + +// 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 := &address.Parameters{ + ClientPubkey: client, + ServerPubkey: server, + Expiry: 10, + PkScript: pkScript, + } + + addrStore := &mockAddressStore{params: []*address.Parameters{addrParams}} + + // Build an address manager using our mock lnd and fake address store. + addrMgr := address.NewManager(&address.ManagerConfig{ + Store: addrStore, + WalletKit: mock.WalletKit, + ChainParams: mock.ChainParams, + // ChainNotifier and AddressClient are not needed for this test. + }, 0) + + // 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, + }, + } + } + + minConfs := int64(deposit.MinConfs) + utxoBelow := makeUtxo(0, minConfs-1) // always included + utxoAt := makeUtxo(1, minConfs) // included only if Deposited + utxoAbove1 := makeUtxo(2, minConfs+1) + utxoAbove2 := makeUtxo(3, minConfs+2) + + // Helper to build the deposit manager with specific states. + buildDepositMgr := func( + states map[wire.OutPoint]fsm.StateType) *deposit.Manager { + + store := &mockDepositStore{ + byOutpoint: make(map[string]*deposit.Deposit), + } + for op, state := range states { + d := &deposit.Deposit{OutPoint: op} + d.SetState(state) + store.byOutpoint[op.String()] = d + } + + return deposit.NewManager(&deposit.ManagerConfig{Store: store}) + } + + // Include below-min-conf and >=min with Deposited; exclude others. + t.Run("below min conf always, Deposited included, others excluded", + func(t *testing.T) { + mock.SetListUnspent([]*lnwallet.Utxo{ + utxoBelow, utxoAt, utxoAbove1, utxoAbove2, + }) + + depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ + utxoAt.OutPoint: deposit.Deposited, + utxoAbove1.OutPoint: deposit.Withdrawn, + utxoAbove2.OutPoint: deposit.LoopingIn, + }) + + server := &swapClientServer{ + staticAddressManager: addrMgr, + depositManager: depMgr, + } + + resp, err := server.ListUnspentDeposits( + ctx, &looprpc.ListUnspentDepositsRequest{}, + ) + require.NoError(t, err) + + // Expect utxoBelow and utxoAt only. + require.Len(t, resp.Utxos, 2) + 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) + } + _, ok1 := got[utxoBelow.OutPoint.String()] + _, ok2 := got[utxoAt.OutPoint.String()] + require.True(t, ok1) + require.True(t, ok2) + }) + + // Swap states, now include utxoBelow and utxoAbove1. + t.Run("Deposited on >=min included; non-Deposited excluded", + func(t *testing.T) { + mock.SetListUnspent( + []*lnwallet.Utxo{ + utxoBelow, utxoAt, utxoAbove1, + utxoAbove2, + }) + + depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{ + utxoAt.OutPoint: deposit.Withdrawn, + utxoAbove1.OutPoint: deposit.Deposited, + utxoAbove2.OutPoint: deposit.Withdrawn, + }) + + server := &swapClientServer{ + staticAddressManager: addrMgr, + depositManager: depMgr, + } + + resp, err := server.ListUnspentDeposits( + ctx, &looprpc.ListUnspentDepositsRequest{}, + ) + require.NoError(t, err) + + require.Len(t, resp.Utxos, 2) + got := map[string]struct{}{} + for _, u := range resp.Utxos { + got[u.Outpoint] = struct{}{} + } + _, ok1 := got[utxoBelow.OutPoint.String()] + _, ok2 := got[utxoAbove1.OutPoint.String()] + require.True(t, ok1) + require.True(t, ok2) + }) + + // Confirmed UTXO not present in store should be included. + t.Run("confirmed utxo not in store is included", func(t *testing.T) { + // Only return a confirmed UTXO from lnd and make sure the + // deposit manager/store doesn't know about it. + mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2}) + + // Empty store (no states for any outpoint). + depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{}) + + server := &swapClientServer{ + staticAddressManager: addrMgr, + depositManager: depMgr, + } + + resp, err := server.ListUnspentDeposits( + ctx, &looprpc.ListUnspentDepositsRequest{}, + ) + require.NoError(t, err) + + // We expect the confirmed UTXO to be included even though it + // doesn't exist in the store yet. + require.Len(t, resp.Utxos, 1) + require.Equal( + t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint, + ) + require.NotEmpty(t, resp.Utxos[0].StaticAddress) + }) +} diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index aaf5c1106..4fe5d9b5e 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -290,3 +290,9 @@ func (s *LndMockServices) SetFeeEstimate(confTarget int32, func (s *LndMockServices) SetMinRelayFee(feeEstimate chainfee.SatPerKWeight) { s.LndServices.WalletKit.(*mockWalletKit).setMinRelayFee(feeEstimate) } + +// SetListUnspent sets the list of UTXOs returned by the mock's WalletKit +// ListUnspent call. +func (s *LndMockServices) SetListUnspent(utxos []*lnwallet.Utxo) { + s.LndServices.WalletKit.(*mockWalletKit).setListUnspent(utxos) +} diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index 70a9f2f41..ee42fa162 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -36,6 +36,9 @@ type mockWalletKit struct { feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight minRelayFee chainfee.SatPerKWeight + + // listUnspent holds test UTXOs to be returned by ListUnspent. + listUnspent []*lnwallet.Utxo } var _ lndclient.WalletKitClient = (*mockWalletKit)(nil) @@ -51,7 +54,7 @@ func (m *mockWalletKit) ListUnspent(ctx context.Context, minConfs, maxConfs int32, opts ...lndclient.ListUnspentOption) ( []*lnwallet.Utxo, error) { - return nil, nil + return m.listUnspent, nil } func (m *mockWalletKit) ListLeases( @@ -184,6 +187,11 @@ func (m *mockWalletKit) setMinRelayFee(fee chainfee.SatPerKWeight) { m.minRelayFee = fee } +// setListUnspent sets the list of UTXOs returned by ListUnspent. +func (m *mockWalletKit) setListUnspent(utxos []*lnwallet.Utxo) { + m.listUnspent = utxos +} + // MinRelayFee returns the current minimum relay fee based on our chain backend // in sat/kw. It can be set with setMinRelayFee. func (m *mockWalletKit) MinRelayFee(