Skip to content

Commit 719701f

Browse files
authored
Merge pull request #995 from hieblmi/listunspent-fix
staticaddr: filter unavailable deposits in `ListUnspentDeposits`
2 parents a2cce3c + a862f8b commit 719701f

File tree

4 files changed

+308
-2
lines changed

4 files changed

+308
-2
lines changed

loopd/swapclient_server.go

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1546,9 +1546,65 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
15461546
return nil, err
15471547
}
15481548

1549-
// Prepare the list response.
1549+
// ListUnspentRaw returns the unspent wallet view of the backing lnd
1550+
// wallet. It might be that deposits show up there that are actually
1551+
// not spendable because they already have been used but not yet spent
1552+
// by the server. We filter out such deposits here.
1553+
var (
1554+
outpoints []string
1555+
isUnspent = make(map[wire.OutPoint]struct{})
1556+
)
1557+
1558+
// Keep track of confirmed outpoints that we need to check against our
1559+
// database.
1560+
confirmedToCheck := make(map[wire.OutPoint]struct{})
1561+
1562+
for _, utxo := range utxos {
1563+
if utxo.Confirmations < deposit.MinConfs {
1564+
// Unconfirmed deposits are always available.
1565+
isUnspent[utxo.OutPoint] = struct{}{}
1566+
} else {
1567+
// Confirmed deposits need to be checked.
1568+
outpoints = append(outpoints, utxo.OutPoint.String())
1569+
confirmedToCheck[utxo.OutPoint] = struct{}{}
1570+
}
1571+
}
1572+
1573+
// Check the spent status of the deposits by looking at their states.
1574+
deposits, err := s.depositManager.DepositsForOutpoints(ctx, outpoints)
1575+
if err != nil {
1576+
return nil, err
1577+
}
1578+
for _, d := range deposits {
1579+
// A nil deposit means we don't have a record for it. We'll
1580+
// handle this case after the loop.
1581+
if d == nil {
1582+
continue
1583+
}
1584+
1585+
// If the deposit is in the "Deposited" state, it's available.
1586+
if d.IsInState(deposit.Deposited) {
1587+
isUnspent[d.OutPoint] = struct{}{}
1588+
}
1589+
1590+
// We have a record for this deposit, so we no longer need to
1591+
// check it.
1592+
delete(confirmedToCheck, d.OutPoint)
1593+
}
1594+
1595+
// Any remaining outpoints in confirmedToCheck are ones that lnd knows
1596+
// about but we don't. These are new, unspent deposits.
1597+
for op := range confirmedToCheck {
1598+
isUnspent[op] = struct{}{}
1599+
}
1600+
1601+
// Prepare the list of unspent deposits for the rpc response.
15501602
var respUtxos []*looprpc.Utxo
15511603
for _, u := range utxos {
1604+
if _, ok := isUnspent[u.OutPoint]; !ok {
1605+
continue
1606+
}
1607+
15521608
utxo := &looprpc.Utxo{
15531609
StaticAddress: staticAddress.String(),
15541610
AmountSat: int64(u.Value),

loopd/swapclient_server_test.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,21 @@ import (
88

99
"github.com/btcsuite/btcd/btcutil"
1010
"github.com/btcsuite/btcd/chaincfg"
11+
"github.com/btcsuite/btcd/chaincfg/chainhash"
12+
"github.com/btcsuite/btcd/wire"
1113
"github.com/btcsuite/btclog/v2"
1214
"github.com/lightninglabs/lndclient"
1315
"github.com/lightninglabs/loop"
16+
"github.com/lightninglabs/loop/fsm"
1417
"github.com/lightninglabs/loop/labels"
1518
"github.com/lightninglabs/loop/loopdb"
1619
"github.com/lightninglabs/loop/looprpc"
20+
"github.com/lightninglabs/loop/staticaddr/address"
21+
"github.com/lightninglabs/loop/staticaddr/deposit"
1722
"github.com/lightninglabs/loop/swap"
1823
mock_lnd "github.com/lightninglabs/loop/test"
1924
"github.com/lightningnetwork/lnd/lntypes"
25+
"github.com/lightningnetwork/lnd/lnwallet"
2026
"github.com/lightningnetwork/lnd/lnwire"
2127
"github.com/lightningnetwork/lnd/routing/route"
2228
"github.com/stretchr/testify/require"
@@ -891,3 +897,233 @@ func TestListSwapsFilterAndPagination(t *testing.T) {
891897
})
892898
}
893899
}
900+
901+
// mockAddressStore is a minimal in-memory store for address parameters.
902+
type mockAddressStore struct {
903+
params []*address.Parameters
904+
}
905+
906+
func (s *mockAddressStore) CreateStaticAddress(_ context.Context,
907+
p *address.Parameters) error {
908+
909+
s.params = append(s.params, p)
910+
return nil
911+
}
912+
913+
func (s *mockAddressStore) GetStaticAddress(_ context.Context, _ []byte) (
914+
*address.Parameters, error) {
915+
916+
if len(s.params) == 0 {
917+
return nil, nil
918+
}
919+
920+
return s.params[0], nil
921+
}
922+
923+
func (s *mockAddressStore) GetAllStaticAddresses(_ context.Context) (
924+
[]*address.Parameters, error) {
925+
926+
return s.params, nil
927+
}
928+
929+
// mockDepositStore implements deposit.Store minimally for DepositsForOutpoints.
930+
type mockDepositStore struct {
931+
byOutpoint map[string]*deposit.Deposit
932+
}
933+
934+
func (s *mockDepositStore) CreateDeposit(_ context.Context,
935+
_ *deposit.Deposit) error {
936+
937+
return nil
938+
}
939+
940+
func (s *mockDepositStore) UpdateDeposit(_ context.Context,
941+
_ *deposit.Deposit) error {
942+
943+
return nil
944+
}
945+
946+
func (s *mockDepositStore) GetDeposit(_ context.Context,
947+
_ deposit.ID) (*deposit.Deposit, error) {
948+
949+
return nil, nil
950+
}
951+
952+
func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
953+
outpoint string) (*deposit.Deposit, error) {
954+
955+
if d, ok := s.byOutpoint[outpoint]; ok {
956+
return d, nil
957+
}
958+
return nil, nil
959+
}
960+
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
961+
error) {
962+
963+
return nil, nil
964+
}
965+
966+
// TestListUnspentDeposits tests filtering behavior of ListUnspentDeposits.
967+
func TestListUnspentDeposits(t *testing.T) {
968+
ctx := context.Background()
969+
mock := mock_lnd.NewMockLnd()
970+
971+
// Prepare a single static address parameter set.
972+
_, client := mock_lnd.CreateKey(1)
973+
_, server := mock_lnd.CreateKey(2)
974+
pkScript := []byte("pkscript")
975+
addrParams := &address.Parameters{
976+
ClientPubkey: client,
977+
ServerPubkey: server,
978+
Expiry: 10,
979+
PkScript: pkScript,
980+
}
981+
982+
addrStore := &mockAddressStore{params: []*address.Parameters{addrParams}}
983+
984+
// Build an address manager using our mock lnd and fake address store.
985+
addrMgr := address.NewManager(&address.ManagerConfig{
986+
Store: addrStore,
987+
WalletKit: mock.WalletKit,
988+
ChainParams: mock.ChainParams,
989+
// ChainNotifier and AddressClient are not needed for this test.
990+
}, 0)
991+
992+
// Construct several UTXOs with different confirmation counts.
993+
makeUtxo := func(idx uint32, confs int64) *lnwallet.Utxo {
994+
return &lnwallet.Utxo{
995+
AddressType: lnwallet.TaprootPubkey,
996+
Value: btcutil.Amount(250_000 + int64(idx)),
997+
Confirmations: confs,
998+
PkScript: pkScript,
999+
OutPoint: wire.OutPoint{
1000+
Hash: chainhash.Hash{byte(idx + 1)},
1001+
Index: idx,
1002+
},
1003+
}
1004+
}
1005+
1006+
minConfs := int64(deposit.MinConfs)
1007+
utxoBelow := makeUtxo(0, minConfs-1) // always included
1008+
utxoAt := makeUtxo(1, minConfs) // included only if Deposited
1009+
utxoAbove1 := makeUtxo(2, minConfs+1)
1010+
utxoAbove2 := makeUtxo(3, minConfs+2)
1011+
1012+
// Helper to build the deposit manager with specific states.
1013+
buildDepositMgr := func(
1014+
states map[wire.OutPoint]fsm.StateType) *deposit.Manager {
1015+
1016+
store := &mockDepositStore{
1017+
byOutpoint: make(map[string]*deposit.Deposit),
1018+
}
1019+
for op, state := range states {
1020+
d := &deposit.Deposit{OutPoint: op}
1021+
d.SetState(state)
1022+
store.byOutpoint[op.String()] = d
1023+
}
1024+
1025+
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
1026+
}
1027+
1028+
// Include below-min-conf and >=min with Deposited; exclude others.
1029+
t.Run("below min conf always, Deposited included, others excluded",
1030+
func(t *testing.T) {
1031+
mock.SetListUnspent([]*lnwallet.Utxo{
1032+
utxoBelow, utxoAt, utxoAbove1, utxoAbove2,
1033+
})
1034+
1035+
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
1036+
utxoAt.OutPoint: deposit.Deposited,
1037+
utxoAbove1.OutPoint: deposit.Withdrawn,
1038+
utxoAbove2.OutPoint: deposit.LoopingIn,
1039+
})
1040+
1041+
server := &swapClientServer{
1042+
staticAddressManager: addrMgr,
1043+
depositManager: depMgr,
1044+
}
1045+
1046+
resp, err := server.ListUnspentDeposits(
1047+
ctx, &looprpc.ListUnspentDepositsRequest{},
1048+
)
1049+
require.NoError(t, err)
1050+
1051+
// Expect utxoBelow and utxoAt only.
1052+
require.Len(t, resp.Utxos, 2)
1053+
got := map[string]struct{}{}
1054+
for _, u := range resp.Utxos {
1055+
got[u.Outpoint] = struct{}{}
1056+
// Confirm address string is non-empty and the
1057+
// same across utxos.
1058+
require.NotEmpty(t, u.StaticAddress)
1059+
}
1060+
_, ok1 := got[utxoBelow.OutPoint.String()]
1061+
_, ok2 := got[utxoAt.OutPoint.String()]
1062+
require.True(t, ok1)
1063+
require.True(t, ok2)
1064+
})
1065+
1066+
// Swap states, now include utxoBelow and utxoAbove1.
1067+
t.Run("Deposited on >=min included; non-Deposited excluded",
1068+
func(t *testing.T) {
1069+
mock.SetListUnspent(
1070+
[]*lnwallet.Utxo{
1071+
utxoBelow, utxoAt, utxoAbove1,
1072+
utxoAbove2,
1073+
})
1074+
1075+
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
1076+
utxoAt.OutPoint: deposit.Withdrawn,
1077+
utxoAbove1.OutPoint: deposit.Deposited,
1078+
utxoAbove2.OutPoint: deposit.Withdrawn,
1079+
})
1080+
1081+
server := &swapClientServer{
1082+
staticAddressManager: addrMgr,
1083+
depositManager: depMgr,
1084+
}
1085+
1086+
resp, err := server.ListUnspentDeposits(
1087+
ctx, &looprpc.ListUnspentDepositsRequest{},
1088+
)
1089+
require.NoError(t, err)
1090+
1091+
require.Len(t, resp.Utxos, 2)
1092+
got := map[string]struct{}{}
1093+
for _, u := range resp.Utxos {
1094+
got[u.Outpoint] = struct{}{}
1095+
}
1096+
_, ok1 := got[utxoBelow.OutPoint.String()]
1097+
_, ok2 := got[utxoAbove1.OutPoint.String()]
1098+
require.True(t, ok1)
1099+
require.True(t, ok2)
1100+
})
1101+
1102+
// Confirmed UTXO not present in store should be included.
1103+
t.Run("confirmed utxo not in store is included", func(t *testing.T) {
1104+
// Only return a confirmed UTXO from lnd and make sure the
1105+
// deposit manager/store doesn't know about it.
1106+
mock.SetListUnspent([]*lnwallet.Utxo{utxoAbove2})
1107+
1108+
// Empty store (no states for any outpoint).
1109+
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
1110+
1111+
server := &swapClientServer{
1112+
staticAddressManager: addrMgr,
1113+
depositManager: depMgr,
1114+
}
1115+
1116+
resp, err := server.ListUnspentDeposits(
1117+
ctx, &looprpc.ListUnspentDepositsRequest{},
1118+
)
1119+
require.NoError(t, err)
1120+
1121+
// We expect the confirmed UTXO to be included even though it
1122+
// doesn't exist in the store yet.
1123+
require.Len(t, resp.Utxos, 1)
1124+
require.Equal(
1125+
t, utxoAbove2.OutPoint.String(), resp.Utxos[0].Outpoint,
1126+
)
1127+
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
1128+
})
1129+
}

test/lnd_services_mock.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,9 @@ func (s *LndMockServices) SetFeeEstimate(confTarget int32,
290290
func (s *LndMockServices) SetMinRelayFee(feeEstimate chainfee.SatPerKWeight) {
291291
s.LndServices.WalletKit.(*mockWalletKit).setMinRelayFee(feeEstimate)
292292
}
293+
294+
// SetListUnspent sets the list of UTXOs returned by the mock's WalletKit
295+
// ListUnspent call.
296+
func (s *LndMockServices) SetListUnspent(utxos []*lnwallet.Utxo) {
297+
s.LndServices.WalletKit.(*mockWalletKit).setListUnspent(utxos)
298+
}

test/walletkit_mock.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ type mockWalletKit struct {
3636
feeEstimateLock sync.Mutex
3737
feeEstimates map[int32]chainfee.SatPerKWeight
3838
minRelayFee chainfee.SatPerKWeight
39+
40+
// listUnspent holds test UTXOs to be returned by ListUnspent.
41+
listUnspent []*lnwallet.Utxo
3942
}
4043

4144
var _ lndclient.WalletKitClient = (*mockWalletKit)(nil)
@@ -51,7 +54,7 @@ func (m *mockWalletKit) ListUnspent(ctx context.Context, minConfs,
5154
maxConfs int32, opts ...lndclient.ListUnspentOption) (
5255
[]*lnwallet.Utxo, error) {
5356

54-
return nil, nil
57+
return m.listUnspent, nil
5558
}
5659

5760
func (m *mockWalletKit) ListLeases(
@@ -184,6 +187,11 @@ func (m *mockWalletKit) setMinRelayFee(fee chainfee.SatPerKWeight) {
184187
m.minRelayFee = fee
185188
}
186189

190+
// setListUnspent sets the list of UTXOs returned by ListUnspent.
191+
func (m *mockWalletKit) setListUnspent(utxos []*lnwallet.Utxo) {
192+
m.listUnspent = utxos
193+
}
194+
187195
// MinRelayFee returns the current minimum relay fee based on our chain backend
188196
// in sat/kw. It can be set with setMinRelayFee.
189197
func (m *mockWalletKit) MinRelayFee(

0 commit comments

Comments
 (0)