mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Document deposit lock ownership for mutable confirmation state and route production reads through deposit accessors. Keep store persistence on no-lock helpers while callers hold the deposit lock, preserving the existing transition behavior without leaving direct field reads in user-facing paths.
530 lines
16 KiB
Go
530 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 := confirmationHeight +
|
|
int64(csvExpiry) - int64(blockHeight)
|
|
|
|
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
|
|
}
|