loop/staticaddr/loopin/autoloop_dp.go
Slyghtning dc7da41b28
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.
2026-07-12 09:14:32 +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
}