staticaddr: expose tracked deposit availability

Build list and summary responses from tracked deposit records instead
of raw wallet UTXOs so RPC clients see the manager availability state.
Split unconfirmed value from confirmed deposited value in summaries,
and reject manual loop-in quotes for selected deposits that are not
currently Deposited.
This commit is contained in:
Slyghtning 2026-07-08 13:51:46 +02:00
parent e4bcc94a36
commit e8dd3aa009
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
4 changed files with 366 additions and 95 deletions

View file

@ -96,7 +96,7 @@ type swapClientServer struct {
reservationManager *reservation.Manager
instantOutManager *instantout.Manager
staticAddressManager *address.Manager
depositManager *deposit.Manager
depositManager staticAddressDepositManager
withdrawalManager *withdraw.Manager
staticLoopInManager *loopin.Manager
openChannelManager *openchannel.Manager
@ -112,6 +112,31 @@ type swapClientServer struct {
stopDaemon func()
}
// staticAddressDepositManager is the deposit manager behavior required by the
// RPC server.
type staticAddressDepositManager interface {
// EnsureDepositsFresh reconciles tracked deposits with lnd's current
// wallet view before user-facing deposit selection.
EnsureDepositsFresh(context.Context) error
// GetActiveDepositsInState returns active deposits that are currently in
// the requested state.
GetActiveDepositsInState(fsm.StateType) ([]*deposit.Deposit, error)
// DepositsForOutpoints returns known deposit records for the requested
// outpoints, optionally skipping unknown outpoints.
DepositsForOutpoints(context.Context, []string, bool) (
[]*deposit.Deposit, error)
// GetVisibleDeposits returns deposits that should be shown in normal
// user-facing views.
GetVisibleDeposits(context.Context) ([]*deposit.Deposit, error)
// GetAllDeposits returns all known deposit records, including historical
// records that are no longer user-visible.
GetAllDeposits(context.Context) ([]*deposit.Deposit, error)
}
// LoopOut initiates a loop out swap with the given parameters. The call returns
// after the swap has been set up with the swap server. From that point onwards,
// progress can be tracked via the LoopOutStatus stream that is returned from
@ -977,15 +1002,22 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
return nil, fmt.Errorf("expected %d deposits, got %d",
len(req.DepositOutpoints),
len(depositList.FilteredDeposits))
} else {
numDeposits = len(depositList.FilteredDeposits)
}
numDeposits = len(depositList.FilteredDeposits)
// In case we quote for deposits, we send the server both the
// selected value and the number of deposits. This is so the
// server can probe the selected value and calculate the per
// input fee.
for _, deposit := range depositList.FilteredDeposits {
// For a manual quote we require the current state to be
// Deposited so a stale client-side outpoint selection
// fails early instead of making it to swap initiation.
if deposit.State != looprpc.DepositState_DEPOSITED {
return nil, fmt.Errorf("deposit %s is not "+
"currently available", deposit.Outpoint)
}
totalDepositAmount += btcutil.Amount(
deposit.Value,
)
@ -1693,58 +1725,40 @@ func (s *swapClientServer) ListUnspentDeposits(ctx context.Context,
}
// 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.
// wallet. Static loop-in initiation requires an active deposit record,
// so only deposits that are both wallet-visible and tracked as
// Deposited are returned 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{}{}
}
outpoints = append(outpoints, utxo.OutPoint.String())
}
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, err
}
// Check the spent status of the deposits by looking at their states.
ignoreUnknownOutpoints := false
ignoreUnknownOutpoints := true
deposits, err := s.depositManager.DepositsForOutpoints(
ctx, outpoints, ignoreUnknownOutpoints,
)
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.
@ -1988,9 +2002,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
for _, d := range ds {
state := toClientDepositState(d.GetState())
confirmationHeight := d.GetConfirmationHeight()
blocksUntilExpiry := confirmationHeight +
int64(addrParams.Expiry) -
int64(lndInfo.BlockHeight)
blocksUntilExpiry := depositBlocksUntilExpiry(
confirmationHeight, addrParams.Expiry,
int64(lndInfo.BlockHeight),
)
pd := &looprpc.Deposit{
Id: d.ID[:],
@ -2082,23 +2097,16 @@ func (s *swapClientServer) GetStaticAddressSummary(ctx context.Context,
htlcTimeoutSwept int64
)
// Value unconfirmed.
utxos, err := s.staticAddressManager.ListUnspent(
ctx, 0, deposit.MinConfs-1,
)
if err != nil {
return nil, err
}
for _, u := range utxos {
valueUnconfirmed += int64(u.Value)
}
// Confirmed total values by category.
// Total values by category.
for _, d := range allDeposits {
value := int64(d.Value)
switch d.GetState() {
case deposit.Deposited:
valueDeposited += value
if d.GetConfirmationHeight() <= 0 {
valueUnconfirmed += value
} else {
valueDeposited += value
}
case deposit.Expired:
valueExpired += value
@ -2248,13 +2256,27 @@ func (s *swapClientServer) populateBlocksUntilExpiry(ctx context.Context,
return err
}
for i := range len(deposits) {
deposits[i].BlocksUntilExpiry =
deposits[i].ConfirmationHeight +
int64(params.Expiry) - bestBlockHeight
deposits[i].BlocksUntilExpiry = depositBlocksUntilExpiry(
deposits[i].ConfirmationHeight, params.Expiry,
bestBlockHeight,
)
}
return nil
}
// depositBlocksUntilExpiry returns the remaining blocks until a deposit
// expires. Unconfirmed deposits return the full CSV value because the timeout
// has not started yet.
func depositBlocksUntilExpiry(confirmationHeight int64, expiry uint32,
bestBlockHeight int64) int64 {
if confirmationHeight <= 0 {
return int64(expiry)
}
return confirmationHeight + int64(expiry) - bestBlockHeight
}
// StaticOpenChannel initiates an open channel request using static address
// deposits.
func (s *swapClientServer) StaticOpenChannel(ctx context.Context,

View file

@ -0,0 +1,23 @@
package loopd
import (
"testing"
)
// TestDepositBlocksUntilExpiry checks blocks-until-expiry handling for
// confirmed and unconfirmed deposits.
func TestDepositBlocksUntilExpiry(t *testing.T) {
t.Run("unconfirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(0, 144, 500); blocks != 144 {
t.Fatalf("expected 144 blocks for unconfirmed deposit, got %d",
blocks)
}
})
t.Run("confirmed", func(t *testing.T) {
if blocks := depositBlocksUntilExpiry(450, 144, 500); blocks != 94 {
t.Fatalf("expected 94 blocks until expiry, got %d",
blocks)
}
})
}

View file

@ -0,0 +1,135 @@
package loopd
import (
"context"
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btclog/v2"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/staticaddr/address"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
mock_lnd "github.com/lightninglabs/loop/test"
"github.com/stretchr/testify/require"
)
type staticAddrDepositStore struct {
allDeposits []*deposit.Deposit
byOutpoint map[string]*deposit.Deposit
}
// CreateDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) CreateDeposit(context.Context,
*deposit.Deposit) error {
return nil
}
// UpdateDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) UpdateDeposit(context.Context,
*deposit.Deposit) error {
return nil
}
// GetDeposit implements deposit.Store for static address server tests.
func (s *staticAddrDepositStore) GetDeposit(context.Context,
deposit.ID) (*deposit.Deposit, error) {
return nil, nil
}
// DepositForOutpoint returns the deposit for the requested outpoint.
func (s *staticAddrDepositStore) DepositForOutpoint(_ context.Context,
outpoint string) (*deposit.Deposit, error) {
if deposit, ok := s.byOutpoint[outpoint]; ok {
return deposit, nil
}
return nil, deposit.ErrDepositNotFound
}
// AllDeposits returns all deposits seeded into the test store.
func (s *staticAddrDepositStore) AllDeposits(context.Context) (
[]*deposit.Deposit, error) {
return s.allDeposits, nil
}
// newTestDepositManager creates a deposit manager backed by seeded deposits.
func newTestDepositManager(
deposits ...*deposit.Deposit) *deposit.Manager {
byOutpoint := make(map[string]*deposit.Deposit, len(deposits))
for _, deposit := range deposits {
byOutpoint[deposit.OutPoint.String()] = deposit
}
return deposit.NewManager(&deposit.ManagerConfig{
Store: &staticAddrDepositStore{
allDeposits: deposits,
byOutpoint: byOutpoint,
},
})
}
// newTestStaticAddressContext creates static address test dependencies.
func newTestStaticAddressContext(t *testing.T) (*address.Manager,
*mock_lnd.LndMockServices) {
t.Helper()
mock := mock_lnd.NewMockLnd()
_, client := mock_lnd.CreateKey(1)
_, server := mock_lnd.CreateKey(2)
addrStore := &mockAddressStore{
params: []*script.Parameters{{
ClientPubkey: client,
ServerPubkey: server,
Expiry: 10,
PkScript: []byte("pkscript"),
}},
}
addrMgr, err := address.NewManager(&address.ManagerConfig{
Store: addrStore,
WalletKit: mock.WalletKit,
ChainParams: mock.ChainParams,
}, 1)
require.NoError(t, err)
return addrMgr, mock
}
// TestGetLoopInQuoteRejectsUnavailableSelectedDeposit verifies manual quote
// requests fail for selected deposits that are no longer available.
func TestGetLoopInQuoteRejectsUnavailableSelectedDeposit(t *testing.T) {
t.Parallel()
setLogger(btclog.Disabled)
locked := &deposit.Deposit{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{6},
Index: 6,
},
Value: btcutil.Amount(5_000),
}
locked.SetState(deposit.LoopingIn)
addrMgr, lnd := newTestStaticAddressContext(t)
server := &swapClientServer{
depositManager: newTestDepositManager(locked),
staticAddressManager: addrMgr,
lnd: &lnd.LndServices,
}
_, err := server.GetLoopInQuote(context.Background(), &looprpc.QuoteRequest{
DepositOutpoints: []string{locked.OutPoint.String()},
})
require.ErrorContains(t, err, "is not currently available")
}

View file

@ -2,6 +2,7 @@ package loopd
import (
"context"
"fmt"
"os"
"testing"
"time"
@ -1321,7 +1322,7 @@ func (s *mockDepositStore) DepositForOutpoint(_ context.Context,
if d, ok := s.byOutpoint[outpoint]; ok {
return d, nil
}
return nil, nil
return nil, deposit.ErrDepositNotFound
}
func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
@ -1335,6 +1336,90 @@ func (s *mockDepositStore) AllDeposits(_ context.Context) ([]*deposit.Deposit,
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()
@ -1376,39 +1461,41 @@ func TestListUnspentDeposits(t *testing.T) {
}
}
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)
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) *deposit.Manager {
states map[wire.OutPoint]fsm.StateType) *listUnspentDepositManager {
store := &mockDepositStore{
depMgr := &listUnspentDepositManager{
byOutpoint: make(map[string]*deposit.Deposit),
}
for op, state := range states {
d := &deposit.Deposit{OutPoint: op}
d.SetState(state)
store.byOutpoint[op.String()] = d
depMgr.byOutpoint[op.String()] = d
}
return deposit.NewManager(&deposit.ManagerConfig{Store: store})
return depMgr
}
// Include below-min-conf and >=min with Deposited; exclude others.
t.Run("below min conf always, Deposited included, others excluded",
// 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{
utxoBelow, utxoAt, utxoAbove1, utxoAbove2,
utxoUnknown, utxoDeposited, utxoWithdrawn,
utxoLoopingIn,
})
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
utxoAt.OutPoint: deposit.Deposited,
utxoAbove1.OutPoint: deposit.Withdrawn,
utxoAbove2.OutPoint: deposit.LoopingIn,
utxoDeposited.OutPoint: deposit.Deposited,
utxoWithdrawn.OutPoint: deposit.Withdrawn,
utxoLoopingIn.OutPoint: deposit.LoopingIn,
})
server := &swapClientServer{
@ -1420,9 +1507,10 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
// Expect utxoBelow and utxoAt only.
require.Len(t, resp.Utxos, 2)
// Expect the Deposited utxo only.
require.Len(t, resp.Utxos, 1)
got := map[string]struct{}{}
for _, u := range resp.Utxos {
got[u.Outpoint] = struct{}{}
@ -1430,25 +1518,23 @@ func TestListUnspentDeposits(t *testing.T) {
// 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)
_, ok := got[utxoDeposited.OutPoint.String()]
require.True(t, ok)
})
// Swap states, now include utxoBelow and utxoAbove1.
t.Run("Deposited on >=min included; non-Deposited excluded",
// 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{
utxoBelow, utxoAt, utxoAbove1,
utxoAbove2,
utxoUnknown, utxoDeposited,
utxoWithdrawn, utxoLoopingIn,
})
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{
utxoAt.OutPoint: deposit.Withdrawn,
utxoAbove1.OutPoint: deposit.Deposited,
utxoAbove2.OutPoint: deposit.Withdrawn,
utxoDeposited.OutPoint: deposit.Deposited,
utxoWithdrawn.OutPoint: deposit.Withdrawn,
utxoLoopingIn.OutPoint: deposit.LoopingIn,
})
server := &swapClientServer{
@ -1460,26 +1546,32 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
require.Len(t, resp.Utxos, 2)
require.Len(t, resp.Utxos, 1)
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)
_, ok := got[utxoDeposited.OutPoint.String()]
require.True(t, ok)
})
// 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})
// 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})
// Empty store (no states for any outpoint).
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,
@ -1490,13 +1582,12 @@ func TestListUnspentDeposits(t *testing.T) {
ctx, &looprpc.ListUnspentDepositsRequest{},
)
require.NoError(t, err)
require.Equal(t, 1, depMgr.ensureDepositsFreshCalls)
// 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,
t, utxoConfirmedUnknown.OutPoint.String(),
resp.Utxos[0].Outpoint,
)
require.NotEmpty(t, resp.Utxos[0].StaticAddress)
})
}