loop/staticaddr/loopin/autoloop_dp.go
Slyghtning 5506d28f79
staticaddr: sync active deposits with wallet
Treat lnd wallet view as the source of spendable static-address
outputs while keeping historical deposit records in the DB. Reconcile
active FSMs against the current wallet view and reactivate known
deposits when their outpoints are visible again.

Refresh deposits before selection, withdrawal, loop-in, and channel-open
paths, and filter list and summary responses through the live active set
so stale Deposited records are not exposed as available funds.
2026-06-30 16:15:04 +02:00

531 lines
16 KiB
Go

package loopin
import (
"errors"
"math/bits"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/staticaddr/deposit"
)
const (
// autoloopDPMaxMemoryBytes caps the selector's working set. The
// selector compresses the sum space when needed so one planning tick
// cannot consume unbounded memory just because a node has many static
// deposits.
autoloopDPMaxMemoryBytes = 128 * 1024 * 1024
// autoloopDPStateOverheadBytes approximates the per-bucket cost outside
// of the bitset itself. The exact sum and count slices account for the
// logical state, and the extra slack keeps the sizing conservative so
// the selector stays below the intended memory budget in practice.
autoloopDPStateOverheadBytes = 16
)
var (
// errAutoloopDPMemoryBudgetTooSmall is returned when even the coarsest
// two-bucket table would exceed the configured memory budget. This is a
// structural limitation of the bounded-memory representation, not a
// liquidity constraint, so callers should not collapse it into
// ErrNoAutoloopCandidate.
errAutoloopDPMemoryBudgetTooSmall = errors.New(
"autoloop dp memory budget too small",
)
)
// autoloopCandidateDeposit carries the precomputed metadata the DP needs to
// make deterministic comparisons.
type autoloopCandidateDeposit struct {
// deposit is the original static-address deposit that may be selected.
deposit *deposit.Deposit
// residualLife is the remaining lifetime of the deposit in blocks once
// the current height is taken into account.
residualLife int64
// outpoint is cached so deterministic ordering does not keep rebuilding
// the string form during sort comparisons.
outpoint string
}
// autoloopDPTable stores one representative subset for each compressed sum
// bucket. Each representative carries its full bitset so later updates can
// compare candidates without relying on mutable predecessor buckets.
//
// This is intentionally heavier than a predecessor-only table. A simpler
// parent-pointer representation would be smaller per bucket, but it becomes
// incorrect once a source bucket is overwritten by a later update because
// already-derived states would silently change their parent chains.
//
// When the selector compresses the sum space, several exact sums can share one
// bucket. The build phase keeps only one representative for that bucket and
// prefers the larger exact sum before the final band scan considers expiry.
// That makes the compressed path approximate: an earlier-expiring smaller-sum
// subset can be hidden by a larger-sum subset in the same bucket. The default
// 128 MiB budget keeps realistic production inputs at step = 1, so this trade-
// off only matters when tests or future callers intentionally lower the memory
// budget.
type autoloopDPTable struct {
// wordsPerState is the number of 64-bit words needed to represent one
// deposit-selection bitset.
wordsPerState int
// exactSums stores the real satoshi sum of the representative subset in
// each bucket. The selector never trusts the compressed bucket weight
// for range checks because the DP is allowed to scale the sum space.
exactSums []btcutil.Amount
// counts stores the number of selected deposits for each bucket. A
// value of -1 marks an unreachable bucket.
counts []int32
// selections stores one flattened bitset per bucket. The bitset is the
// self-contained reconstruction data for the representative subset.
selections []uint64
}
// selectNoChangeDepositsWithMemoryBudget runs the bounded-memory selector with
// an explicit budget. Tests use this entry point to force the scaling path and
// to exercise hard budget failures deterministically.
func selectNoChangeDepositsWithMemoryBudget(maxAmount, minAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{},
maxMemoryBytes int) ([]*deposit.Deposit, error) {
eligibleDeposits, eligibleTotal := filterAutoloopCandidateDeposits(
maxAmount, unfilteredDeposits, csvExpiry, blockHeight,
excludedOutpoints,
)
if len(eligibleDeposits) == 0 || eligibleTotal < minAmount {
return nil, ErrNoAutoloopCandidate
}
step, bucketCount, err := autoloopDPSizing(
maxAmount, len(eligibleDeposits), maxMemoryBytes,
)
if err != nil {
return nil, err
}
table := newAutoloopDPTable(bucketCount, len(eligibleDeposits))
// The empty subset is the base state for all later transitions.
table.counts[0] = 0
for depositIndex, candidateDeposit := range eligibleDeposits {
weight := autoloopDPBucketWeight(
candidateDeposit.deposit.Value, step,
)
// Descending updates preserve the 0-1 constraint: every deposit
// is either present once in a candidate or not at all.
start := bucketCount - weight - 1
for sourceBucket := start; sourceBucket >= 0; sourceBucket-- {
if !table.isReachable(sourceBucket) {
continue
}
destBucket := sourceBucket + weight
candidateSum := table.exactSums[sourceBucket] +
candidateDeposit.deposit.Value
if candidateSum > maxAmount {
continue
}
beats := table.candidateBeatsState(
sourceBucket, destBucket, depositIndex,
candidateSum, eligibleDeposits,
)
if !beats {
continue
}
table.copyStateFromSource(
destBucket, sourceBucket, depositIndex,
candidateSum,
)
}
}
bestTotal := btcutil.Amount(-1)
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < minAmount || exactSum > maxAmount {
continue
}
if exactSum > bestTotal {
bestTotal = exactSum
}
}
if bestTotal < 0 {
return nil, ErrNoAutoloopCandidate
}
// The band lets expiry management influence the final choice, but only
// after the selector first learns the best liquidity amount that the
// compressed DP table can achieve. The slack gives back up to 25 percent
// of the gain above minAmount, not 25 percent of bestTotal itself.
slack := (bestTotal - minAmount) / 4
bandFloor := bestTotal - slack
bestBucket := -1
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < bandFloor || exactSum > bestTotal {
continue
}
if bestBucket == -1 {
bestBucket = bucket
continue
}
beats := table.stateBeatsStateWithinBand(
bucket, bestBucket, eligibleDeposits,
)
if beats {
bestBucket = bucket
}
}
if bestBucket == -1 {
return nil, ErrNoAutoloopCandidate
}
selectedIndices := table.selectedIndices(bestBucket)
selectedDeposits := make([]*deposit.Deposit, 0, len(selectedIndices))
for _, index := range selectedIndices {
selectedDeposits = append(
selectedDeposits, eligibleDeposits[index].deposit,
)
}
return selectedDeposits, nil
}
// filterAutoloopCandidateDeposits removes deposits that can never participate
// in a full-deposit static autoloop suggestion and sorts the remainder in the
// order used by the expiry comparisons.
func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{}) (
[]autoloopCandidateDeposit, btcutil.Amount) {
eligibleDeposits := make(
[]autoloopCandidateDeposit, 0, len(unfilteredDeposits),
)
var eligibleTotal btcutil.Amount
for _, candidateDeposit := range unfilteredDeposits {
outpoint := candidateDeposit.OutPoint.String()
if _, ok := excludedOutpoints[outpoint]; ok {
continue
}
confirmationHeight := candidateDeposit.GetConfirmationHeight()
swappable := IsSwappable(
uint32(confirmationHeight), blockHeight, csvExpiry,
)
if !swappable {
continue
}
if candidateDeposit.Value > maxAmount {
continue
}
residualLife := int64(blocksUntilDepositExpiry(
uint32(confirmationHeight), blockHeight, csvExpiry,
))
eligibleDeposits = append(
eligibleDeposits, autoloopCandidateDeposit{
deposit: candidateDeposit,
residualLife: residualLife,
outpoint: outpoint,
},
)
eligibleTotal += candidateDeposit.Value
}
// The DP compares reconstructed residual-life sequences directly.
// Sorting deposits by residual life first keeps those later comparisons
// exact and deterministic without inventing a scalar "urgency score".
sort.Slice(eligibleDeposits, func(i, j int) bool {
left := eligibleDeposits[i]
right := eligibleDeposits[j]
switch {
case left.residualLife != right.residualLife:
return left.residualLife < right.residualLife
case left.deposit.Value != right.deposit.Value:
return left.deposit.Value > right.deposit.Value
}
return left.outpoint < right.outpoint
})
return eligibleDeposits, eligibleTotal
}
// autoloopDPSizing chooses the smallest bucket step that keeps the compressed
// table within the configured memory budget.
func autoloopDPSizing(maxAmount btcutil.Amount, depositCount,
maxMemoryBytes int) (btcutil.Amount, int, error) {
wordsPerState := autoloopDPWordsPerState(depositCount)
stateBytes := wordsPerState*8 + autoloopDPStateOverheadBytes
maxBuckets := maxMemoryBytes / stateBytes
// The selector needs at least bucket zero plus one positive bucket.
if maxBuckets < 2 {
return 0, 0, errAutoloopDPMemoryBudgetTooSmall
}
step := ceilAmountDiv(maxAmount, btcutil.Amount(maxBuckets-1))
step = max(step, 1)
bucketCount := int(ceilAmountDiv(maxAmount, step)) + 1
return step, bucketCount, nil
}
// autoloopDPWordsPerState returns the number of 64-bit words needed to encode
// one subset bitset for the current deposit count.
func autoloopDPWordsPerState(depositCount int) int {
return (depositCount + 63) / 64
}
// autoloopDPBucketWeight compresses a deposit value into one DP bucket weight.
//
// The table rounds down, but never below one bucket. Rounding up each deposit
// would accidentally reject some valid exact sums once several per-item
// round-up errors accumulate. The exact-sum guard remains the real safety
// boundary: compressed weights only decide which representative states are kept
// in memory, never whether a candidate is allowed to exceed maxAmount.
func autoloopDPBucketWeight(value, step btcutil.Amount) int {
weight := int(value / step)
if weight == 0 {
return 1
}
return weight
}
// ceilAmountDiv performs positive ceiling division for amount sizing.
func ceilAmountDiv(numerator, denominator btcutil.Amount) btcutil.Amount {
if numerator <= 0 {
return 0
}
return (numerator + denominator - 1) / denominator
}
// newAutoloopDPTable allocates the bounded-memory DP table.
func newAutoloopDPTable(bucketCount, depositCount int) *autoloopDPTable {
wordsPerState := autoloopDPWordsPerState(depositCount)
counts := make([]int32, bucketCount)
for i := range counts {
counts[i] = -1
}
return &autoloopDPTable{
wordsPerState: wordsPerState,
exactSums: make([]btcutil.Amount, bucketCount),
counts: counts,
selections: make([]uint64, bucketCount*wordsPerState),
}
}
// isReachable reports whether a bucket currently has a representative subset.
func (t *autoloopDPTable) isReachable(bucket int) bool {
return t.counts[bucket] >= 0
}
// stateWords returns the flattened bitset slice for one bucket.
func (t *autoloopDPTable) stateWords(bucket int) []uint64 {
start := bucket * t.wordsPerState
end := start + t.wordsPerState
return t.selections[start:end]
}
// copyStateFromSource writes a winning candidate into the destination bucket.
func (t *autoloopDPTable) copyStateFromSource(destBucket, sourceBucket,
depositIndex int, exactSum btcutil.Amount) {
destWords := t.stateWords(destBucket)
sourceWords := t.stateWords(sourceBucket)
copy(destWords, sourceWords)
wordIndex := depositIndex / 64
bitIndex := uint(depositIndex % 64)
destWords[wordIndex] |= uint64(1) << bitIndex
t.exactSums[destBucket] = exactSum
t.counts[destBucket] = t.counts[sourceBucket] + 1
}
// candidateBeatsState reports whether the candidate obtained by extending the
// source bucket with one deposit should replace the destination bucket.
func (t *autoloopDPTable) candidateBeatsState(sourceBucket, destBucket,
depositIndex int, candidateSum btcutil.Amount,
deposits []autoloopCandidateDeposit) bool {
if !t.isReachable(destBucket) {
return true
}
existingSum := t.exactSums[destBucket]
if candidateSum != existingSum {
return candidateSum > existingSum
}
candidateCount := int(t.counts[sourceBucket]) + 1
existingCount := int(t.counts[destBucket])
if candidateCount != existingCount {
return candidateCount < existingCount
}
// Only exact-sum and exact-count ties fall through to expiry
// comparison. That keeps the expensive residual-life reconstruction
// off the hot path.
return t.candidateEarlierThanState(
sourceBucket, destBucket, depositIndex, deposits,
)
}
// candidateEarlierThanState compares the candidate residual-life sequence to
// the existing destination sequence.
func (t *autoloopDPTable) candidateEarlierThanState(sourceBucket, destBucket,
depositIndex int, deposits []autoloopCandidateDeposit) bool {
candidateResidualLives := t.candidateResidualLives(
sourceBucket, depositIndex, deposits,
)
existingResidualLives := t.stateResidualLives(destBucket, deposits)
return compareResidualLifeSequences(
candidateResidualLives, existingResidualLives,
) < 0
}
// stateBeatsStateWithinBand applies the final band-local ordering:
// 1. earlier-expiring deposits.
// 2. larger exact total.
// 3. fewer deposits.
func (t *autoloopDPTable) stateBeatsStateWithinBand(leftBucket, rightBucket int,
deposits []autoloopCandidateDeposit) bool {
leftResidualLives := t.stateResidualLives(leftBucket, deposits)
rightResidualLives := t.stateResidualLives(rightBucket, deposits)
cmp := compareResidualLifeSequences(
leftResidualLives, rightResidualLives,
)
switch cmp {
case -1:
return true
case 1:
return false
}
leftSum := t.exactSums[leftBucket]
rightSum := t.exactSums[rightBucket]
if leftSum != rightSum {
return leftSum > rightSum
}
return t.counts[leftBucket] < t.counts[rightBucket]
}
// selectedIndices reconstructs the selected deposit indices for a bucket in
// sorted order. The caller must pass a reachable bucket.
func (t *autoloopDPTable) selectedIndices(bucket int) []int {
count := int(t.counts[bucket])
if count < 0 {
panic("selectedIndices called on unreachable bucket")
}
selectedIndices := make([]int, 0, count)
for wordIndex, word := range t.stateWords(bucket) {
for word != 0 {
bitIndex := bits.TrailingZeros64(word)
selectedIndices = append(
selectedIndices, wordIndex*64+bitIndex,
)
word &^= uint64(1) << uint(bitIndex)
}
}
return selectedIndices
}
// candidateResidualLives reconstructs the candidate residual-life sequence.
// The current deposit index is always larger than every index already present
// in the source bucket, so appending preserves the sorted order.
func (t *autoloopDPTable) candidateResidualLives(sourceBucket, depositIndex int,
deposits []autoloopCandidateDeposit) []int64 {
residualLives := t.stateResidualLives(sourceBucket, deposits)
residualLives = append(
residualLives, deposits[depositIndex].residualLife,
)
return residualLives
}
// stateResidualLives reconstructs the sorted residual-life sequence for a
// bucket.
func (t *autoloopDPTable) stateResidualLives(bucket int,
deposits []autoloopCandidateDeposit) []int64 {
selectedIndices := t.selectedIndices(bucket)
residualLives := make([]int64, 0, len(selectedIndices))
for _, index := range selectedIndices {
residualLives = append(
residualLives, deposits[index].residualLife,
)
}
return residualLives
}
// compareResidualLifeSequences compares two sorted residual-life sequences.
//
// A smaller residual-life value means the deposit expires sooner and is thus
// more urgent to consume. The comparison intentionally stops at the shorter
// length: if one sequence is a strict prefix of the other, expiry alone does
// not provide a principled winner and the caller falls back to amount and
// deposit-count tie-breakers instead of inventing an arbitrary preference for
// the longer or shorter set.
func compareResidualLifeSequences(left, right []int64) int {
limit := min(len(left), len(right))
for i := range limit {
switch {
case left[i] < right[i]:
return -1
case left[i] > right[i]:
return 1
}
}
return 0
}