Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 3 additions & 2 deletions staticaddr/deposit/fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions staticaddr/deposit/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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{
Expand Down
103 changes: 103 additions & 0 deletions staticaddr/deposit/registry.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions staticaddr/loopin/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions staticaddr/loopin/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions staticaddr/loopin/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading