staticaddr: refresh deposits before spend selection

Refresh the active static-address deposit set against lnd's wallet view
before quote, loop-in, withdrawal, channel-open, and autoloop selection
paths. This prevents stale persisted Deposited records from being
selected after replacement, reorg, or an external spend.
This commit is contained in:
Slyghtning 2026-07-08 13:55:04 +02:00
parent 1c89ff83f1
commit dc7da41b28
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
13 changed files with 112 additions and 15 deletions

View file

@ -946,6 +946,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
// number of deposits to quote for. // number of deposits to quote for.
numDeposits := 0 numDeposits := 0
if autoSelectDeposits { if autoSelectDeposits {
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, err := s.depositManager.GetActiveDepositsInState( deposits, err := s.depositManager.GetActiveDepositsInState(
deposit.Deposited, deposit.Deposited,
) )
@ -980,6 +986,12 @@ func (s *swapClientServer) GetLoopInQuote(ctx context.Context,
numDeposits = len(selectedDeposits) numDeposits = len(selectedDeposits)
} else if len(req.DepositOutpoints) > 0 { } else if len(req.DepositOutpoints) > 0 {
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
// If deposits are selected, we need to retrieve them to // If deposits are selected, we need to retrieve them to
// calculate the total value which we request a quote for. // calculate the total value which we request a quote for.
depositList, err := s.ListStaticAddressDeposits( depositList, err := s.ListStaticAddressDeposits(
@ -1798,6 +1810,12 @@ func (s *swapClientServer) WithdrawDeposits(ctx context.Context,
return nil, fmt.Errorf("must select either all or some utxos") return nil, fmt.Errorf("must select either all or some utxos")
case isAllSelected: case isAllSelected:
err = s.depositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, err := s.depositManager.GetActiveDepositsInState( deposits, err := s.depositManager.GetActiveDepositsInState(
deposit.Deposited, deposit.Deposited,
) )

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcd/wire"
@ -13,6 +14,7 @@ import (
"github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/script"
mock_lnd "github.com/lightninglabs/loop/test" mock_lnd "github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lnwallet"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -60,6 +62,33 @@ func (s *staticAddrDepositStore) AllDeposits(context.Context) (
return s.allDeposits, nil return s.allDeposits, nil
} }
type staticAddrTestAddressManager struct{}
func (s *staticAddrTestAddressManager) GetStaticAddressParameters(
context.Context) (*script.Parameters, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) GetStaticAddress(
context.Context) (*script.StaticAddress, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) ListUnspent(context.Context,
int32, int32) ([]*lnwallet.Utxo, error) {
return nil, nil
}
func (s *staticAddrTestAddressManager) GetTaprootAddress(
*btcec.PublicKey, *btcec.PublicKey, int64) (*btcutil.AddressTaproot,
error) {
return nil, nil
}
// newTestDepositManager creates a deposit manager backed by seeded deposits. // newTestDepositManager creates a deposit manager backed by seeded deposits.
func newTestDepositManager( func newTestDepositManager(
deposits ...*deposit.Deposit) *deposit.Manager { deposits ...*deposit.Deposit) *deposit.Manager {
@ -70,6 +99,7 @@ func newTestDepositManager(
} }
return deposit.NewManager(&deposit.ManagerConfig{ return deposit.NewManager(&deposit.ManagerConfig{
AddressManager: &staticAddrTestAddressManager{},
Store: &staticAddrDepositStore{ Store: &staticAddrDepositStore{
allDeposits: deposits, allDeposits: deposits,
byOutpoint: byOutpoint, byOutpoint: byOutpoint,

View file

@ -784,6 +784,11 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) (
// noopDepositManager is a stub DepositManager used to satisfy FSM config. // noopDepositManager is a stub DepositManager used to satisfy FSM config.
type noopDepositManager struct{} type noopDepositManager struct{}
// EnsureDepositsFresh implements DepositManager with a no-op.
func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
// GetAllDeposits implements DepositManager with a no-op. // GetAllDeposits implements DepositManager with a no-op.
func (n *noopDepositManager) GetAllDeposits(_ context.Context) ( func (n *noopDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) { []*deposit.Deposit, error) {

View file

@ -30,6 +30,11 @@ func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context,
return nil, 0, false, ErrNoAutoloopCandidate return nil, 0, false, ErrNoAutoloopCandidate
} }
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, 0, false, err
}
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
deposit.Deposited, deposit.Deposited,
) )

View file

@ -247,8 +247,7 @@ func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
} }
residualLife := int64(blocksUntilDepositExpiry( residualLife := int64(blocksUntilDepositExpiry(
uint32(confirmationHeight), uint32(confirmationHeight), blockHeight, csvExpiry,
blockHeight, csvExpiry,
)) ))
eligibleDeposits = append( eligibleDeposits = append(

View file

@ -45,6 +45,9 @@ type AddressManager interface {
// DepositManager handles the interaction of loop-ins with deposits. // DepositManager handles the interaction of loop-ins with deposits.
type DepositManager interface { type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// GetAllDeposits returns all known deposits from the database store. // GetAllDeposits returns all known deposits from the database store.
GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error) GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error)

View file

@ -630,6 +630,11 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
selectedDeposits []*deposit.Deposit selectedDeposits []*deposit.Deposit
) )
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w", err)
}
// Determine which deposits to use for the loop-in swap. If none are // Determine which deposits to use for the loop-in swap. If none are
// selected by the client, we will coin-select them based on the amount. // selected by the client, we will coin-select them based on the amount.
switch { switch {
@ -876,8 +881,8 @@ func SelectDeposits(targetAmount btcutil.Amount,
// prefers deposits the server can accept immediately. Within each group // prefers deposits the server can accept immediately. Within each group
// we prefer larger deposits, then earlier expiries. // we prefer larger deposits, then earlier expiries.
sort.Slice(deposits, func(i, j int) bool { sort.Slice(deposits, func(i, j int) bool {
iConfirmationHeight := uint32(deposits[i].GetConfirmationHeight()) iConfirmationHeight := deposits[i].GetConfirmationHeight()
jConfirmationHeight := uint32(deposits[j].GetConfirmationHeight()) jConfirmationHeight := deposits[j].GetConfirmationHeight()
iConfirmed := iConfirmationHeight > 0 iConfirmed := iConfirmationHeight > 0
jConfirmed := jConfirmationHeight > 0 jConfirmed := jConfirmationHeight > 0
if iConfirmed != jConfirmed { if iConfirmed != jConfirmed {
@ -886,10 +891,12 @@ func SelectDeposits(targetAmount btcutil.Amount,
if deposits[i].Value == deposits[j].Value { if deposits[i].Value == deposits[j].Value {
iExp := blocksUntilDepositExpiry( iExp := blocksUntilDepositExpiry(
iConfirmationHeight, blockHeight, csvExpiry, uint32(iConfirmationHeight), blockHeight,
csvExpiry,
) )
jExp := blocksUntilDepositExpiry( jExp := blocksUntilDepositExpiry(
jConfirmationHeight, blockHeight, csvExpiry, uint32(jConfirmationHeight), blockHeight,
csvExpiry,
) )
return iExp < jExp return iExp < jExp

View file

@ -393,6 +393,10 @@ type mockDepositManager struct {
byOutpoint map[string]*deposit.Deposit byOutpoint map[string]*deposit.Deposit
} }
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
[]*deposit.Deposit, error) { []*deposit.Deposit, error) {

View file

@ -12,6 +12,9 @@ import (
) )
type DepositManager interface { type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// AllOutpointsActiveDeposits returns all deposits that are in the // AllOutpointsActiveDeposits returns all deposits that are in the
// given state. If the state filter is fsm.StateTypeNone, all deposits // given state. If the state filter is fsm.StateTypeNone, all deposits
// are returned. // are returned.

View file

@ -267,11 +267,6 @@ func (m *Manager) OpenChannel(ctx context.Context,
).FeePerKWeight() ).FeePerKWeight()
} }
// There are three ways in which we select deposits to open a channel
// with. 1.) The user manually selects the deposits. 2.) The user only
// selects a local channel amount in which case we coin-select deposits
// to cover for it. 3.) The user selects the fundmax flag, in which case
// we select all deposits to fund the channel.
if len(req.Outpoints) > 0 { if len(req.Outpoints) > 0 {
// Ensure that the deposits are in a state in which they are // Ensure that the deposits are in a state in which they are
// available for a channel open. // available for a channel open.
@ -288,6 +283,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, fmt.Errorf("%w in request", err) return nil, fmt.Errorf("%w in request", err)
} }
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
deposits, allActive = deposits, allActive =
m.cfg.DepositManager.AllOutpointsActiveDeposits( m.cfg.DepositManager.AllOutpointsActiveDeposits(
outpoints, deposit.Deposited, outpoints, deposit.Deposited,
@ -296,6 +297,12 @@ func (m *Manager) OpenChannel(ctx context.Context,
return nil, ErrOpeningChannelUnavailableDeposits return nil, ErrOpeningChannelUnavailableDeposits
} }
} else { } else {
err = m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return nil, fmt.Errorf("unable to refresh deposits: %w",
err)
}
// We have to select the deposits that are used to fund the // We have to select the deposits that are used to fund the
// channel. // channel.
deposits, err = m.cfg.DepositManager.GetActiveDepositsInState( deposits, err = m.cfg.DepositManager.GetActiveDepositsInState(

View file

@ -36,6 +36,10 @@ type mockDepositManager struct {
calls []transitionCall calls []transitionCall
} }
func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
return nil
}
func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint,
fsm.StateType) ([]*deposit.Deposit, bool) { fsm.StateType) ([]*deposit.Deposit, bool) {

View file

@ -21,14 +21,24 @@ type AddressManager interface {
} }
type DepositManager interface { type DepositManager interface {
// EnsureDepositsFresh reconciles active deposits with the wallet view.
EnsureDepositsFresh(ctx context.Context) error
// GetActiveDepositsInState returns all active deposits in the given
// state.
GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit, GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit,
error) error)
// AllOutpointsActiveDeposits returns all active deposits referenced by
// the outpoints if every deposit is active and in the given state.
AllOutpointsActiveDeposits(outpoints []wire.OutPoint, AllOutpointsActiveDeposits(outpoints []wire.OutPoint,
stateFilter fsm.StateType) ([]*deposit.Deposit, bool) stateFilter fsm.StateType) ([]*deposit.Deposit, bool)
// TransitionDeposits transitions the deposits with the given event and
// waits until they reach the expected final state.
TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit, TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit,
event fsm.EventType, expectedFinalState fsm.StateType) error event fsm.EventType, expectedFinalState fsm.StateType) error
// UpdateDeposit persists the current deposit fields.
UpdateDeposit(ctx context.Context, d *deposit.Deposit) error UpdateDeposit(ctx context.Context, d *deposit.Deposit) error
} }

View file

@ -320,6 +320,11 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
allWithdrawing bool allWithdrawing bool
) )
err := m.cfg.DepositManager.EnsureDepositsFresh(ctx)
if err != nil {
return "", "", fmt.Errorf("unable to refresh deposits: %w", err)
}
// Ensure that the deposits are in a state in which they can be // Ensure that the deposits are in a state in which they can be
// withdrawn. // withdrawn.
deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits( deposits, allDeposited = m.cfg.DepositManager.AllOutpointsActiveDeposits(
@ -390,10 +395,7 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
} }
} }
var ( var withdrawalAddress btcutil.Address
withdrawalAddress btcutil.Address
err error
)
// Check if the user provided an address to withdraw to. If not, we'll // Check if the user provided an address to withdraw to. If not, we'll
// generate a new address for them. // generate a new address for them.