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.

Keep withdrawal and channel-open flows on confirmed inputs by rejecting
unconfirmed selected deposits in those paths.
This commit is contained in:
Slyghtning 2026-06-26 12:50:00 +02:00
parent 32b3b9650f
commit ad8e2ea6ca
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
7 changed files with 457 additions and 99 deletions

View file

@ -977,15 +977,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 +1700,35 @@ 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())
}
// 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.
@ -1791,8 +1775,9 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
return nil, err
}
for _, d := range deposits {
outpoints = append(outpoints, d.OutPoint)
outpoints, err = withdrawAllDepositOutpoints(deposits)
if err != nil {
return nil, err
}
case isUtxoSelected:
@ -1815,6 +1800,25 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
}, err
}
// withdrawAllDepositOutpoints returns all deposit outpoints for an `all`
// withdrawal request. The request must fail if any deposited output is still
// unconfirmed because `all` should not silently downgrade to a subset.
func withdrawAllDepositOutpoints(deposits []*deposit.Deposit) ([]wire.OutPoint,
error) {
outpoints := make([]wire.OutPoint, 0, len(deposits))
for _, d := range deposits {
if d.ConfirmationHeight <= 0 {
return nil, fmt.Errorf("can't withdraw all deposits while " +
"some deposits are unconfirmed")
}
outpoints = append(outpoints, d.OutPoint)
}
return outpoints, nil
}
// ListStaticAddressDeposits returns a list of all sufficiently confirmed
// deposits behind the static address and displays properties like value,
// state or blocks til expiry.
@ -1987,9 +1991,10 @@ func (s *swapClientServer) ListStaticAddressSwaps(ctx context.Context,
protoDeposits = make([]*looprpc.Deposit, 0, len(ds))
for _, d := range ds {
state := toClientDepositState(d.GetState())
blocksUntilExpiry := d.ConfirmationHeight +
int64(addrParams.Expiry) -
int64(lndInfo.BlockHeight)
blocksUntilExpiry := depositBlocksUntilExpiry(
d.ConfirmationHeight, addrParams.Expiry,
int64(lndInfo.BlockHeight),
)
pd := &looprpc.Deposit{
Id: d.ID[:],
@ -2081,23 +2086,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.ConfirmationHeight <= 0 {
valueUnconfirmed += value
} else {
valueDeposited += value
}
case deposit.Expired:
valueExpired += value
@ -2247,13 +2245,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,86 @@
package loopd
import (
"testing"
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/staticaddr/deposit"
)
// 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)
}
})
}
// TestWithdrawAllDepositOutpoints checks `all` withdrawal handling for
// confirmed and unconfirmed deposits.
func TestWithdrawAllDepositOutpoints(t *testing.T) {
t.Run("rejects unconfirmed", func(t *testing.T) {
deposits := []*deposit.Deposit{
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{1},
Index: 1,
},
},
{
OutPoint: wire.OutPoint{
Hash: chainhash.Hash{2},
Index: 2,
},
ConfirmationHeight: 123,
},
}
_, err := withdrawAllDepositOutpoints(deposits)
if err == nil {
t.Fatal("expected unconfirmed deposit to fail all withdrawal")
}
})
t.Run("returns all confirmed", func(t *testing.T) {
first := wire.OutPoint{
Hash: chainhash.Hash{3},
Index: 3,
}
second := wire.OutPoint{
Hash: chainhash.Hash{4},
Index: 4,
}
deposits := []*deposit.Deposit{
{
OutPoint: first,
ConfirmationHeight: 123,
},
{
OutPoint: second,
ConfirmationHeight: 124,
},
}
outpoints, err := withdrawAllDepositOutpoints(deposits)
if err != nil {
t.Fatalf("expected confirmed deposits to succeed: %v", err)
}
if len(outpoints) != 2 {
t.Fatalf("expected 2 outpoints, got %d", len(outpoints))
}
if outpoints[0] != first || outpoints[1] != second {
t.Fatal("expected all confirmed outpoints to remain selected")
}
})
}

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

@ -1321,7 +1321,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,
@ -1376,11 +1376,11 @@ 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(
@ -1398,17 +1398,19 @@ func TestListUnspentDeposits(t *testing.T) {
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",
// 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{
@ -1421,8 +1423,8 @@ func TestListUnspentDeposits(t *testing.T) {
)
require.NoError(t, err)
// 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 +1432,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{
@ -1461,22 +1461,20 @@ func TestListUnspentDeposits(t *testing.T) {
)
require.NoError(t, err)
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) {
// Confirmed UTXO not present in store should be excluded.
t.Run("confirmed utxo not in store is excluded", 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})
mock.SetListUnspent([]*lnwallet.Utxo{utxoConfirmedUnknown})
// Empty store (no states for any outpoint).
depMgr := buildDepositMgr(map[wire.OutPoint]fsm.StateType{})
@ -1491,12 +1489,6 @@ func TestListUnspentDeposits(t *testing.T) {
)
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)
require.Empty(t, resp.Utxos)
})
}