mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
staticaddr: coordinate concurrent deposit use
Withdrawals obtain signatures before deposits enter their durable state, leaving a window where a loop-in can select the same outputs. Coordinate in-flight client operations with an in-memory registry. Persist withdrawal data before publication. Existing recovery resumes it after restart.
This commit is contained in:
parent
8f831e85ae
commit
e0367e552a
10 changed files with 212 additions and 41 deletions
|
|
@ -111,3 +111,9 @@ then be swapped for an off-chain Lightning payment.
|
|||
HTLC transaction, which then follows a standard Loop-In flow. When the
|
||||
client gets the LN payment, they cooperate with the server to sweep the
|
||||
deposit directly to the server's wallet instead of publishing the HTLC tx.
|
||||
|
||||
- **Concurrent Use:** The client keeps an in-memory registry while a deposit
|
||||
is being prepared for a Loop-In or withdrawal. This prevents overlapping
|
||||
operations before the deposit reaches its persisted FSM state. The registry
|
||||
starts empty after a restart, leaving recovery to persisted deposit state
|
||||
and wallet reconciliation.
|
||||
|
|
|
|||
|
|
@ -353,8 +353,9 @@ func (f *FSM) DepositStatesV0() fsm.States {
|
|||
OnRecover: Withdrawing,
|
||||
|
||||
// A precondition for the Withdrawing state is
|
||||
// that the withdrawal transaction has been
|
||||
// broadcast. If the deposit expires while the
|
||||
// that a finalized withdrawal transaction has
|
||||
// been persisted for automatic publication and
|
||||
// recovery. If the deposit expires while the
|
||||
// withdrawal isn't confirmed, we can ignore the
|
||||
// expiry.
|
||||
OnExpiry: Withdrawing,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ type ManagerConfig struct {
|
|||
type Manager struct {
|
||||
cfg *ManagerConfig
|
||||
|
||||
// useRegistry coordinates concurrent client operations that use the
|
||||
// same deposits.
|
||||
useRegistry depositUseRegistry
|
||||
|
||||
// mu guards access to the activeDeposits map.
|
||||
mu sync.Mutex
|
||||
|
||||
|
|
@ -91,6 +95,12 @@ type Manager struct {
|
|||
currentHeight atomic.Uint32
|
||||
}
|
||||
|
||||
// RegisterDepositUse registers the deposits for exclusive use by one client
|
||||
// operation. The returned function unregisters only that operation's use.
|
||||
func (m *Manager) RegisterDepositUse(deposits []*Deposit) (func(), error) {
|
||||
return m.useRegistry.register(deposits)
|
||||
}
|
||||
|
||||
// NewManager creates a new deposit manager.
|
||||
func NewManager(cfg *ManagerConfig) *Manager {
|
||||
return &Manager{
|
||||
|
|
|
|||
103
staticaddr/deposit/registry.go
Normal file
103
staticaddr/deposit/registry.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package deposit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDepositInUse is returned when another client operation is already
|
||||
// using one of the requested deposits.
|
||||
ErrDepositInUse = errors.New("deposit already in use")
|
||||
)
|
||||
|
||||
// depositUseRegistration identifies a single use of one or more deposits.
|
||||
// Its pointer identity prevents delayed cleanup from unregistering a newer
|
||||
// operation.
|
||||
type depositUseRegistration struct {
|
||||
// Give each registration non-zero size so distinct pointer identity is
|
||||
// guaranteed.
|
||||
_ byte
|
||||
}
|
||||
|
||||
// depositUseRegistry coordinates in-flight client operations that use static
|
||||
// address deposits. It is intentionally kept in memory so a client restart
|
||||
// clears incomplete registrations and lets persisted deposit state drive
|
||||
// recovery.
|
||||
type depositUseRegistry struct {
|
||||
mu sync.Mutex
|
||||
|
||||
registrations map[wire.OutPoint]*depositUseRegistration
|
||||
}
|
||||
|
||||
// register records the deposits as being in use and returns an owner-safe
|
||||
// cleanup function. Either all deposits are registered or none are.
|
||||
func (r *depositUseRegistry) register(deposits []*Deposit) (func(), error) {
|
||||
if len(deposits) == 0 {
|
||||
return nil, errors.New("no deposits selected")
|
||||
}
|
||||
|
||||
// Copy the outpoints up front so the cleanup closure does not depend on
|
||||
// caller-owned deposit pointers after this method returns.
|
||||
outpoints := make([]wire.OutPoint, len(deposits))
|
||||
for i, d := range deposits {
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("nil deposit at index %d", i)
|
||||
}
|
||||
|
||||
outpoints[i] = d.OutPoint
|
||||
}
|
||||
|
||||
// Reject duplicate inputs before taking the registry lock. A duplicate
|
||||
// would otherwise make ownership of the cleanup entry ambiguous.
|
||||
if err := CheckDuplicates(outpoints); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Keep the conflict check and registration under the same lock so a
|
||||
// request for multiple deposits is registered atomically.
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Check all outpoints before changing the map. This ensures a conflict
|
||||
// leaves every requested deposit unregistered by this operation.
|
||||
for _, outpoint := range outpoints {
|
||||
if _, ok := r.registrations[outpoint]; ok {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDepositInUse,
|
||||
outpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the map lazily because the registry's zero value is ready
|
||||
// for use and is embedded directly in the deposit manager.
|
||||
if r.registrations == nil {
|
||||
r.registrations = make(
|
||||
map[wire.OutPoint]*depositUseRegistration,
|
||||
)
|
||||
}
|
||||
|
||||
// Use one registration token for the whole request. The token lets the
|
||||
// cleanup closure prove that it still owns each entry it removes.
|
||||
registration := &depositUseRegistration{}
|
||||
for _, outpoint := range outpoints {
|
||||
r.registrations[outpoint] = registration
|
||||
}
|
||||
|
||||
return func() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, outpoint := range outpoints {
|
||||
// Only remove entries still owned by this registration. This
|
||||
// makes repeated or delayed cleanup safe if a newer operation
|
||||
// has since registered the same outpoint.
|
||||
current, ok := r.registrations[outpoint]
|
||||
if ok && current == registration {
|
||||
delete(r.registrations, outpoint)
|
||||
}
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -3354,6 +3354,13 @@ func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error {
|
|||
return n.ensureFreshErr
|
||||
}
|
||||
|
||||
// RegisterDepositUse implements DepositManager with a no-op.
|
||||
func (n *noopDepositManager) RegisterDepositUse(
|
||||
[]*deposit.Deposit) (func(), error) {
|
||||
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// GetAllDeposits implements DepositManager with a no-op.
|
||||
func (n *noopDepositManager) GetAllDeposits(_ context.Context) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ type DepositManager interface {
|
|||
// EnsureDepositsFresh reconciles active deposits with the wallet view.
|
||||
EnsureDepositsFresh(ctx context.Context) error
|
||||
|
||||
// RegisterDepositUse registers the deposits for exclusive use by this
|
||||
// client operation and returns a function that unregisters them.
|
||||
RegisterDepositUse(deposits []*deposit.Deposit) (func(), error)
|
||||
|
||||
// GetAllDeposits returns all known deposits from the database store.
|
||||
GetAllDeposits(ctx context.Context) ([]*deposit.Deposit, error)
|
||||
|
||||
|
|
|
|||
|
|
@ -694,6 +694,14 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
unregister, err := m.cfg.DepositManager.RegisterDepositUse(
|
||||
selectedDeposits,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to register deposit use: %w", err)
|
||||
}
|
||||
defer unregister()
|
||||
|
||||
// Calculate the total deposit amount and check if the selected amount
|
||||
// would leave a dust output.
|
||||
swapAmount, err := DeduceSwapAmount(
|
||||
|
|
|
|||
|
|
@ -436,6 +436,14 @@ func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// RegisterDepositUse implements DepositManager by accepting all requested
|
||||
// deposits without tracking their use.
|
||||
func (m *mockDepositManager) RegisterDepositUse(
|
||||
[]*deposit.Deposit) (func(), error) {
|
||||
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
func (m *mockDepositManager) GetAllDeposits(_ context.Context) (
|
||||
[]*deposit.Deposit, error) {
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ type DepositManager interface {
|
|||
// EnsureDepositsFresh reconciles active deposits with the wallet view.
|
||||
EnsureDepositsFresh(ctx context.Context) error
|
||||
|
||||
// RegisterDepositUse registers the deposits for exclusive use by this
|
||||
// client operation and returns a function that unregisters them.
|
||||
RegisterDepositUse(deposits []*deposit.Deposit) (func(), error)
|
||||
|
||||
// GetActiveDepositsInState returns all active deposits in the given
|
||||
// state.
|
||||
GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit,
|
||||
|
|
|
|||
|
|
@ -386,6 +386,13 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
unregister, err := m.cfg.DepositManager.RegisterDepositUse(deposits)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to register deposit use: %w",
|
||||
err)
|
||||
}
|
||||
defer unregister()
|
||||
|
||||
for _, d := range deposits {
|
||||
// Deposited now includes mempool outputs for static loop-ins, but
|
||||
// withdrawals still require the deposit input to be confirmed.
|
||||
|
|
@ -439,21 +446,40 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
return "", "", err
|
||||
}
|
||||
|
||||
published, err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !published {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
withdrawalPkScript, err := txscript.PayToAddrScript(withdrawalAddress)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("could not get withdrawal "+
|
||||
"pkscript: %w", err)
|
||||
}
|
||||
|
||||
// Attach the finalized transaction before transitioning the deposits.
|
||||
// The state transition persists it so recovery can republish after a
|
||||
// restart or an ambiguous publication result.
|
||||
previousWithdrawalTxns := make([]*wire.MsgTx, len(deposits))
|
||||
for i, d := range deposits {
|
||||
d.Lock()
|
||||
previousWithdrawalTxns[i] = d.FinalizedWithdrawalTx
|
||||
d.FinalizedWithdrawalTx = finalizedTx
|
||||
d.Unlock()
|
||||
}
|
||||
|
||||
// Transition before publishing so the persisted deposit state takes
|
||||
// over from the in-memory use registry before the transaction can spend
|
||||
// the selected deposits.
|
||||
err = m.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, deposits, deposit.OnWithdrawInitiated, deposit.Withdrawing,
|
||||
)
|
||||
if err != nil {
|
||||
for i, d := range deposits {
|
||||
d.Lock()
|
||||
d.FinalizedWithdrawalTx = previousWithdrawalTxns[i]
|
||||
d.Unlock()
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("failed to transition deposits %w",
|
||||
err)
|
||||
}
|
||||
|
||||
// If this is the first time this cluster of deposits is withdrawn, we
|
||||
// start a goroutine that listens for the spent of the first input of
|
||||
// the withdrawal transaction.
|
||||
|
|
@ -479,49 +505,43 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
|||
// If a previous withdrawal existed across the selected deposits, and
|
||||
// it isn't the same as the new withdrawal, we remove it from the
|
||||
// finalized withdrawals to stop republishing it on block arrivals.
|
||||
deposits[0].Lock()
|
||||
prevTx := deposits[0].FinalizedWithdrawalTx
|
||||
deposits[0].Unlock()
|
||||
previousWithdrawalTx := previousWithdrawalTxns[0]
|
||||
if previousWithdrawalTx != nil &&
|
||||
previousWithdrawalTx.TxHash() != finalizedTx.TxHash() {
|
||||
|
||||
if prevTx != nil && prevTx.TxHash() != finalizedTx.TxHash() {
|
||||
m.mu.Lock()
|
||||
delete(m.finalizedWithdrawalTxns, prevTx.TxHash())
|
||||
delete(m.finalizedWithdrawalTxns, previousWithdrawalTx.TxHash())
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Attach the finalized withdrawal tx to the deposits. After a client
|
||||
// restart we can use this address as an indicator to republish the
|
||||
// withdrawal tx and continue the withdrawal.
|
||||
// Deposits with the same withdrawal tx are part of the same withdrawal.
|
||||
for _, d := range deposits {
|
||||
d.Lock()
|
||||
d.FinalizedWithdrawalTx = finalizedTx
|
||||
d.Unlock()
|
||||
}
|
||||
|
||||
// Add the new withdrawal tx to the finalized withdrawals to republish
|
||||
// it on block arrivals.
|
||||
m.mu.Lock()
|
||||
m.finalizedWithdrawalTxns[finalizedTx.TxHash()] = finalizedTx
|
||||
m.mu.Unlock()
|
||||
|
||||
// Transition the deposits to the withdrawing state. If the user fee
|
||||
// bumped a withdrawal this results in a NOOP transition.
|
||||
err = m.cfg.DepositManager.TransitionDeposits(
|
||||
ctx, deposits, deposit.OnWithdrawInitiated, deposit.Withdrawing,
|
||||
)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to transition deposits %w",
|
||||
err)
|
||||
// A fee bump is a self-transition, which the deposit FSM doesn't
|
||||
// persist. Store the replacement transaction explicitly in that case.
|
||||
if allWithdrawing {
|
||||
for _, d := range deposits {
|
||||
err = m.cfg.DepositManager.UpdateDeposit(ctx, d)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to update "+
|
||||
"deposit %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the deposits in the database.
|
||||
for _, d := range deposits {
|
||||
err = m.cfg.DepositManager.UpdateDeposit(ctx, d)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to update "+
|
||||
"deposit %w", err)
|
||||
}
|
||||
// Publish only after the finalized transaction and withdrawal state are
|
||||
// durable. Keep that preparation on publication errors because the wallet
|
||||
// RPC outcome can be ambiguous and recovery can retry automatically.
|
||||
published, err := m.publishFinalizedWithdrawalTx(ctx, finalizedTx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if !published {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
return finalizedTx.TxID(), withdrawalAddress.String(), nil
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue