mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
liquidity: add static autoloop planner
Wire static-address-backed loop-ins into the existing autoloop planner and dispatch path. Loop-in rules can now be converted into static candidates, prepared after global sorting, filtered with static fee limits, and dispatched through the static manager. This also fixes MaxAutoInFlight enforcement across all suggested swap types and adds planner tests for missing static candidates and mixed in-flight filtering.
This commit is contained in:
parent
e467ac932b
commit
58ebb042ff
4 changed files with 617 additions and 21 deletions
|
|
@ -213,6 +213,19 @@ type Config struct {
|
|||
LoopIn func(ctx context.Context,
|
||||
request *loop.LoopInRequest) (*loop.LoopInSwapInfo, error)
|
||||
|
||||
// PrepareStaticLoopIn builds a static-address-backed loop-in request
|
||||
// for autoloop without dispatching it. The excluded outpoints set lets
|
||||
// the planner avoid reusing deposits across multiple suggestions from
|
||||
// the same pass.
|
||||
PrepareStaticLoopIn func(ctx context.Context, peer route.Vertex,
|
||||
minAmount, amount btcutil.Amount, label, initiator string,
|
||||
excludedOutpoints []string) (*PreparedStaticLoopIn, error)
|
||||
|
||||
// StaticLoopIn dispatches a prepared static-address-backed loop-in.
|
||||
StaticLoopIn func(ctx context.Context,
|
||||
request *loop.StaticAddressLoopInRequest) (
|
||||
*StaticLoopInDispatchResult, error)
|
||||
|
||||
// LoopInTerms returns the terms for a loop in swap.
|
||||
LoopInTerms func(ctx context.Context,
|
||||
initiator string) (*loop.LoopInTerms, error)
|
||||
|
|
@ -502,6 +515,30 @@ func (m *Manager) autoloop(ctx context.Context) error {
|
|||
loopIn.HtlcAddressP2WSH, loopIn.HtlcAddressP2TR)
|
||||
}
|
||||
|
||||
for _, in := range suggestion.StaticInSwaps {
|
||||
// Static loop-ins follow the same dry-run semantics as the legacy
|
||||
// autoloop suggestions. We only dispatch them when autoloop is
|
||||
// actually enabled.
|
||||
if !m.params.Autoloop {
|
||||
log.Debugf("recommended static autoloop in: %v sats "+
|
||||
"over %v", in.SelectedAmount, in.DepositOutpoints)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if m.cfg.StaticLoopIn == nil {
|
||||
return errors.New("static loop in dispatcher unavailable")
|
||||
}
|
||||
|
||||
loopIn, err := m.cfg.StaticLoopIn(ctx, &in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("static loop in automatically dispatched: hash: %v",
|
||||
loopIn.SwapHash)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +699,7 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no eligible channel for easy autoloop")
|
||||
}
|
||||
|
|
@ -865,6 +903,7 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no eligible channel for easy autoloop")
|
||||
}
|
||||
|
|
@ -961,6 +1000,9 @@ func (s *Suggestions) addSwap(swap swapSuggestion) error {
|
|||
case *loopInSwapSuggestion:
|
||||
s.InSwaps = append(s.InSwaps, t.LoopInRequest)
|
||||
|
||||
case *staticLoopInSwapSuggestion:
|
||||
s.StaticInSwaps = append(s.StaticInSwaps, t.request)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unexpected swap type: %T", swap)
|
||||
}
|
||||
|
|
@ -968,6 +1010,25 @@ func (s *Suggestions) addSwap(swap swapSuggestion) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// count returns the total number of accepted suggestions regardless of swap
|
||||
// type.
|
||||
func (s *Suggestions) count() int {
|
||||
return len(s.OutSwaps) + len(s.InSwaps) + len(s.StaticInSwaps)
|
||||
}
|
||||
|
||||
// suggestionCandidate is the shared view used while ordering suggestions
|
||||
// before final budget and in-flight filtering.
|
||||
type suggestionCandidate interface {
|
||||
// amount returns the requested swap amount.
|
||||
amount() btcutil.Amount
|
||||
|
||||
// channels returns the channels implicated by the candidate.
|
||||
channels() []lnwire.ShortChannelID
|
||||
|
||||
// peers returns the peers implicated by the candidate.
|
||||
peers(knownChans map[uint64]route.Vertex) []route.Vertex
|
||||
}
|
||||
|
||||
// singleReasonSuggestion is a helper function which returns a set of
|
||||
// suggestions where all of our rules are disqualified due to a reason that
|
||||
// applies to all of them (such as being out of budget).
|
||||
|
|
@ -985,12 +1046,11 @@ func (m *Manager) singleReasonSuggestion(reason Reason) *Suggestions {
|
|||
return resp
|
||||
}
|
||||
|
||||
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
|
||||
// balance for the set of rules configured for the manager, failing if there are
|
||||
// no rules set. It takes an autoloop boolean that indicates whether the
|
||||
// suggestions are being used for our internal autolooper. This boolean is used
|
||||
// to determine the information we add to our swap suggestion and whether we
|
||||
// return any suggestions.
|
||||
// SuggestSwaps returns a set of swap suggestions based on our current
|
||||
// liquidity balance for the rules configured on the manager. The planner
|
||||
// fails when no rules are set and otherwise returns both suggested swaps and
|
||||
// structured disqualification reasons for rules that could not be satisfied in
|
||||
// the current pass.
|
||||
func (m *Manager) SuggestSwaps(ctx context.Context) (
|
||||
*Suggestions, error) {
|
||||
|
||||
|
|
@ -1086,8 +1146,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
)
|
||||
|
||||
var (
|
||||
suggestions []swapSuggestion
|
||||
resp = newSuggestions()
|
||||
candidates []suggestionCandidate
|
||||
resp = newSuggestions()
|
||||
)
|
||||
|
||||
for peer, balances := range peerChannels {
|
||||
|
|
@ -1110,7 +1170,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, suggestion)
|
||||
candidates = append(candidates, suggestion)
|
||||
}
|
||||
|
||||
for _, channel := range channels {
|
||||
|
|
@ -1145,18 +1205,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, suggestion)
|
||||
candidates = append(candidates, suggestion)
|
||||
}
|
||||
|
||||
// If we have no swaps to execute after we have applied all of our
|
||||
// limits, just return our set of disqualified swaps.
|
||||
if len(suggestions) == 0 {
|
||||
if len(candidates) == 0 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Sort suggestions by amount in descending order.
|
||||
sort.SliceStable(suggestions, func(i, j int) bool {
|
||||
return suggestions[i].amount() > suggestions[j].amount()
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
return candidates[i].amount() > candidates[j].amount()
|
||||
})
|
||||
|
||||
// Run through our suggested swaps in descending order of amount and
|
||||
|
|
@ -1165,8 +1225,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
|
||||
// setReason is a helper that adds a swap's channels to our disqualified
|
||||
// list with the reason provided.
|
||||
setReason := func(reason Reason, swap swapSuggestion) {
|
||||
for _, peer := range swap.peers(channelPeers) {
|
||||
setReason := func(reason Reason, candidate suggestionCandidate) {
|
||||
for _, peer := range candidate.peers(channelPeers) {
|
||||
_, ok := m.params.PeerRules[peer]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -1175,7 +1235,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
resp.DisqualifiedPeers[peer] = reason
|
||||
}
|
||||
|
||||
for _, channel := range swap.channels() {
|
||||
for _, channel := range candidate.channels() {
|
||||
_, ok := m.params.ChannelRules[channel]
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -1185,7 +1245,40 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
}
|
||||
}
|
||||
|
||||
for _, swap := range suggestions {
|
||||
var excludedOutpoints []string
|
||||
for _, candidate := range candidates {
|
||||
var swap swapSuggestion
|
||||
|
||||
switch t := candidate.(type) {
|
||||
case swapSuggestion:
|
||||
swap = t
|
||||
|
||||
case *staticLoopInCandidate:
|
||||
swap, excludedOutpoints, err = m.prepareStaticLoopInSuggestion(
|
||||
ctx, t, excludedOutpoints,
|
||||
)
|
||||
switch {
|
||||
case errors.Is(err, ErrNoStaticLoopInCandidate):
|
||||
setReason(ReasonStaticLoopInNoCandidate, candidate)
|
||||
continue
|
||||
|
||||
case err == nil:
|
||||
|
||||
default:
|
||||
var reasonErr *reasonError
|
||||
if errors.As(err, &reasonErr) {
|
||||
setReason(reasonErr.reason, candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected candidate type: %T",
|
||||
candidate)
|
||||
}
|
||||
|
||||
// If we do not have enough funds available, or we hit our
|
||||
// in flight limit, we record this value for the rest of the
|
||||
// swaps.
|
||||
|
|
@ -1194,12 +1287,12 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
case available == 0:
|
||||
reason = ReasonBudgetInsufficient
|
||||
|
||||
case len(resp.OutSwaps) == allowedSwaps:
|
||||
case resp.count() == allowedSwaps:
|
||||
reason = ReasonInFlight
|
||||
}
|
||||
|
||||
if reason != ReasonNone {
|
||||
setReason(reason, swap)
|
||||
setReason(reason, candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -1221,7 +1314,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
log.Infof("Swap fee exceeds budget, remaining budget: "+
|
||||
"%v, swap fee %v, next budget refresh: %v",
|
||||
available, fees, refreshTime)
|
||||
setReason(ReasonBudgetInsufficient, swap)
|
||||
setReason(ReasonBudgetInsufficient, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1240,11 +1333,66 @@ func (m *Manager) loadStaticLoopIns(ctx context.Context) (
|
|||
return m.cfg.ListStaticLoopIn(ctx)
|
||||
}
|
||||
|
||||
// prepareStaticLoopInSuggestion turns a peer-level static loop-in candidate
|
||||
// into a concrete swap suggestion. The helper only runs after all candidates
|
||||
// have been sorted so it can carry a mutable excluded-deposit set through the
|
||||
// whole planner pass.
|
||||
func (m *Manager) prepareStaticLoopInSuggestion(ctx context.Context,
|
||||
candidate *staticLoopInCandidate,
|
||||
excludedOutpoints []string) (swapSuggestion, []string, error) {
|
||||
|
||||
if m.cfg.PrepareStaticLoopIn == nil {
|
||||
return nil, excludedOutpoints, errors.New(
|
||||
"static loop in preparer unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
label := ""
|
||||
if m.params.Autoloop {
|
||||
label = labels.AutoloopLabel(swap.TypeIn)
|
||||
if m.params.EasyAutoloop {
|
||||
label = labels.EasyAutoloopLabel(swap.TypeIn)
|
||||
}
|
||||
}
|
||||
|
||||
prepared, err := m.cfg.PrepareStaticLoopIn(
|
||||
ctx, candidate.peer, candidate.minAmount, candidate.amountHint,
|
||||
label,
|
||||
getInitiator(m.params), excludedOutpoints,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, excludedOutpoints, err
|
||||
}
|
||||
|
||||
// Static loop-ins have a different timeout-risk profile than
|
||||
// wallet-funded loop-ins, so use the dedicated static fee model before
|
||||
// the candidate can compete for budget and in-flight slots.
|
||||
err = staticLoopInFeeLimit(
|
||||
m.params.FeeLimit, prepared.Request.SelectedAmount,
|
||||
prepared.Request.MaxSwapFee, prepared.NumDeposits,
|
||||
prepared.HasChange,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, excludedOutpoints, err
|
||||
}
|
||||
|
||||
nextExcluded := append(
|
||||
append([]string(nil), excludedOutpoints...),
|
||||
prepared.Request.DepositOutpoints...,
|
||||
)
|
||||
|
||||
return &staticLoopInSwapSuggestion{
|
||||
request: prepared.Request,
|
||||
numDeposits: prepared.NumDeposits,
|
||||
hasChange: prepared.HasChange,
|
||||
}, nextExcluded, nil
|
||||
}
|
||||
|
||||
// suggestSwap checks whether we can currently perform a swap, and creates a
|
||||
// swap request for the rule provided.
|
||||
func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
|
||||
balance *balances, rule *SwapRule, outRestrictions *Restrictions,
|
||||
inRestrictions *Restrictions) (swapSuggestion, error) {
|
||||
inRestrictions *Restrictions) (suggestionCandidate, error) {
|
||||
|
||||
var (
|
||||
builder swapBuilder
|
||||
|
|
@ -1288,6 +1436,23 @@ func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
|
|||
return nil, newReasonError(ReasonLiquidityOk)
|
||||
}
|
||||
|
||||
// Static loop-ins are prepared later, once the planner has a sorted
|
||||
// view of all loop-in candidates. That later step needs a mutable set
|
||||
// of excluded deposits so that two suggestions in the same pass cannot
|
||||
// consume the same static funds.
|
||||
if rule.Type == swap.TypeIn &&
|
||||
m.params.LoopInSource == LoopInSourceStaticAddress {
|
||||
|
||||
return &staticLoopInCandidate{
|
||||
peer: balance.pubkey,
|
||||
minAmount: restrictions.Minimum,
|
||||
amountHint: amount,
|
||||
channelSet: append(
|
||||
[]lnwire.ShortChannelID(nil), balance.channels...,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return builder.buildSwap(
|
||||
ctx, balance.pubkey, balance.channels, amount, m.params,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,58 @@
|
|||
package liquidity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/lightninglabs/loop"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoStaticLoopInCandidate is returned when the static-address side
|
||||
// is unable to build a full-deposit, no-change candidate for an
|
||||
// autoloop target. This sentinel lets the planner surface a structured
|
||||
// reason without silently falling back to wallet-funded loop-ins.
|
||||
ErrNoStaticLoopInCandidate = errors.New("no static loop-in candidate")
|
||||
)
|
||||
|
||||
// Compile-time assertion that static loop-in suggestions satisfy the shared
|
||||
// swap suggestion interface.
|
||||
var _ swapSuggestion = (*staticLoopInSwapSuggestion)(nil)
|
||||
|
||||
// PreparedStaticLoopIn contains the dry-run data that liquidity needs in order
|
||||
// to represent and account for a static loop-in suggestion.
|
||||
type PreparedStaticLoopIn struct {
|
||||
// Request is the fully specified loop-in request that should be used if
|
||||
// the suggestion is later dispatched.
|
||||
Request loop.StaticAddressLoopInRequest
|
||||
|
||||
// NumDeposits is the number of deposits selected for the request. We
|
||||
// keep this separate so that fee estimation does not need to inspect
|
||||
// any static-address-specific types.
|
||||
NumDeposits int
|
||||
|
||||
// HasChange indicates whether the selected deposits would create
|
||||
// change. The initial autoloop implementation always keeps this false,
|
||||
// but the flag is included so that future partial-selection modes can
|
||||
// reuse the same accounting path safely.
|
||||
HasChange bool
|
||||
}
|
||||
|
||||
// StaticLoopInDispatchResult contains the values that autoloop logs after a
|
||||
// static loop-in is dispatched.
|
||||
type StaticLoopInDispatchResult struct {
|
||||
// SwapHash is the static loop-in swap identifier.
|
||||
SwapHash lntypes.Hash
|
||||
}
|
||||
|
||||
// StaticLoopInInfo contains the persisted data that liquidity needs for budget
|
||||
// accounting and peer traffic tracking.
|
||||
type StaticLoopInInfo struct {
|
||||
|
|
@ -53,6 +95,92 @@ type StaticLoopInInfo struct {
|
|||
HasChange bool
|
||||
}
|
||||
|
||||
// staticLoopInSwapSuggestion is the suggested representation of a static loop
|
||||
// in request.
|
||||
type staticLoopInSwapSuggestion struct {
|
||||
// request is the request that will be dispatched if autoloop executes
|
||||
// the suggestion.
|
||||
request loop.StaticAddressLoopInRequest
|
||||
|
||||
// numDeposits is the number of deposits consumed by the swap. This
|
||||
// feeds the conservative HTLC fee estimate used for budget filtering.
|
||||
numDeposits int
|
||||
|
||||
// hasChange indicates whether the suggestion would create change.
|
||||
hasChange bool
|
||||
}
|
||||
|
||||
// staticLoopInCandidate is the pre-preparation representation of a static
|
||||
// loop-in rule match. It carries the peer target and desired amount so the
|
||||
// planner can sort candidates before allocating concrete deposits.
|
||||
type staticLoopInCandidate struct {
|
||||
// peer is the target peer for the loop-in.
|
||||
peer route.Vertex
|
||||
|
||||
// minAmount is the minimum swap size that the eventual full-deposit
|
||||
// selection must still satisfy after any allowed undershoot.
|
||||
minAmount btcutil.Amount
|
||||
|
||||
// amountHint is the maximum amount that the planner should try to cover
|
||||
// with full-deposit static selection.
|
||||
amountHint btcutil.Amount
|
||||
|
||||
// channelSet carries the peer aggregate's channels so disqualification
|
||||
// reasons can still be attached consistently during later filtering.
|
||||
channelSet []lnwire.ShortChannelID
|
||||
}
|
||||
|
||||
// amount returns the desired amount for the candidate.
|
||||
func (s *staticLoopInCandidate) amount() btcutil.Amount {
|
||||
return s.amountHint
|
||||
}
|
||||
|
||||
// channels returns the channels that belong to the target peer aggregate.
|
||||
func (s *staticLoopInCandidate) channels() []lnwire.ShortChannelID {
|
||||
return s.channelSet
|
||||
}
|
||||
|
||||
// peers returns the single target peer for the candidate.
|
||||
func (s *staticLoopInCandidate) peers(
|
||||
_ map[uint64]route.Vertex) []route.Vertex {
|
||||
|
||||
return []route.Vertex{s.peer}
|
||||
}
|
||||
|
||||
// amount returns the selected swap amount for the suggestion.
|
||||
func (s *staticLoopInSwapSuggestion) amount() btcutil.Amount {
|
||||
return s.request.SelectedAmount
|
||||
}
|
||||
|
||||
// fees returns the worst-case fee estimate for a static loop-in suggestion.
|
||||
func (s *staticLoopInSwapSuggestion) fees() btcutil.Amount {
|
||||
// The actual HTLC fee rate is only known once the server returns the
|
||||
// signed HTLC packages during initiation. For dry-run planning we use
|
||||
// the same conservative fee-rate constant that loop-in sweep budgeting
|
||||
// already uses so that static suggestions do not undercount timeout
|
||||
// risk.
|
||||
return staticLoopInWorstCaseFees(
|
||||
s.numDeposits, s.hasChange, s.request.MaxSwapFee,
|
||||
defaultLoopInSweepFee, defaultLoopInSweepFee,
|
||||
)
|
||||
}
|
||||
|
||||
// channels returns no channels because loop-in rules are peer-scoped.
|
||||
func (s *staticLoopInSwapSuggestion) channels() []lnwire.ShortChannelID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// peers returns the peer that the static loop-in suggestion targets.
|
||||
func (s *staticLoopInSwapSuggestion) peers(
|
||||
_ map[uint64]route.Vertex) []route.Vertex {
|
||||
|
||||
if s.request.LastHop == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []route.Vertex{*s.request.LastHop}
|
||||
}
|
||||
|
||||
// staticLoopInWorstCaseFees returns the larger of the cooperative success fee
|
||||
// and the timeout-path fee for a static loop-in.
|
||||
func staticLoopInWorstCaseFees(numDeposits int, hasChange bool,
|
||||
|
|
@ -120,3 +248,52 @@ func staticLoopInHtlcWeight(numDeposits int,
|
|||
|
||||
return estimator.Weight()
|
||||
}
|
||||
|
||||
// staticLoopInFeeLimit checks a static loop-in candidate against the active
|
||||
// fee policy using the static swap's own worst-case fee model instead of the
|
||||
// legacy wallet-funded loop-in assumptions.
|
||||
func staticLoopInFeeLimit(feeLimit FeeLimit, amount, swapFee btcutil.Amount,
|
||||
numDeposits int, hasChange bool) error {
|
||||
|
||||
switch limit := feeLimit.(type) {
|
||||
case *FeeCategoryLimit:
|
||||
maxServerFee := ppmToSat(amount, limit.MaximumSwapFeePPM)
|
||||
if swapFee > maxServerFee {
|
||||
return newReasonError(ReasonSwapFee)
|
||||
}
|
||||
|
||||
// We do not know the final HTLC fee rate until the server
|
||||
// returns concrete HTLC packages during initiation, so the
|
||||
// planner has to reuse the same conservative default that
|
||||
// dry-run budget filtering already uses.
|
||||
onchainFees := staticLoopInOnchainFee(
|
||||
numDeposits, hasChange, defaultLoopInSweepFee,
|
||||
defaultLoopInSweepFee,
|
||||
)
|
||||
|
||||
if onchainFees > limit.MaximumMinerFee {
|
||||
return newReasonError(ReasonMinerFee)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case *FeePortion:
|
||||
totalFeeSpend := ppmToSat(amount, limit.PartsPerMillion)
|
||||
if swapFee > totalFeeSpend {
|
||||
return newReasonError(ReasonSwapFee)
|
||||
}
|
||||
|
||||
fees := staticLoopInWorstCaseFees(
|
||||
numDeposits, hasChange, swapFee, defaultLoopInSweepFee,
|
||||
defaultLoopInSweepFee,
|
||||
)
|
||||
if fees > totalFeeSpend {
|
||||
return newReasonError(ReasonFeePPMInsufficient)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown fee limit: %T", feeLimit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
"github.com/lightninglabs/loop"
|
||||
"github.com/lightninglabs/loop/labels"
|
||||
"github.com/lightninglabs/loop/swap"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -274,3 +278,197 @@ func TestCurrentSwapTrafficStatic(t *testing.T) {
|
|||
require.False(t, traffic.ongoingLoopIn[route.Vertex{3}])
|
||||
require.Equal(t, testTime, traffic.failedLoopIn[peer2])
|
||||
}
|
||||
|
||||
// TestSuggestSwapsStaticLoopInNoCandidate verifies that the planner surfaces a
|
||||
// structured disqualification reason when static selection cannot build a
|
||||
// full-deposit candidate for a peer rule.
|
||||
func TestSuggestSwapsStaticLoopInNoCandidate(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
cfg.PrepareStaticLoopIn = func(context.Context, route.Vertex,
|
||||
btcutil.Amount, btcutil.Amount, string, string,
|
||||
[]string) (*PreparedStaticLoopIn, error) {
|
||||
|
||||
return nil, ErrNoStaticLoopInCandidate
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{
|
||||
{
|
||||
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
|
||||
PubKeyBytes: peer1,
|
||||
LocalBalance: 1_000,
|
||||
RemoteBalance: 9_000,
|
||||
Capacity: 10_000,
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
params.LoopInSource = LoopInSourceStaticAddress
|
||||
params.PeerRules = map[route.Vertex]*SwapRule{
|
||||
peer1: {
|
||||
ThresholdRule: NewThresholdRule(0, 50),
|
||||
Type: swap.TypeIn,
|
||||
},
|
||||
}
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
suggestions, err := manager.SuggestSwaps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, suggestions.StaticInSwaps)
|
||||
require.Equal(
|
||||
t, ReasonStaticLoopInNoCandidate,
|
||||
suggestions.DisqualifiedPeers[peer1],
|
||||
)
|
||||
}
|
||||
|
||||
// TestSuggestSwapsMixedInFlightCount verifies that static loop-ins consume the
|
||||
// same accepted-suggestion slots as legacy swaps during final filtering.
|
||||
func TestSuggestSwapsMixedInFlightCount(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
|
||||
_, _ btcutil.Amount, label, initiator string,
|
||||
_ []string) (*PreparedStaticLoopIn, error) {
|
||||
|
||||
return &PreparedStaticLoopIn{
|
||||
Request: loop.StaticAddressLoopInRequest{
|
||||
DepositOutpoints: []string{"static:0"},
|
||||
SelectedAmount: 4_000,
|
||||
MaxSwapFee: 20,
|
||||
LastHop: &peer,
|
||||
Label: label,
|
||||
Initiator: initiator,
|
||||
},
|
||||
NumDeposits: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{
|
||||
channel1,
|
||||
{
|
||||
ChannelID: lnwire.NewShortChanIDFromInt(20).ToUint64(),
|
||||
PubKeyBytes: peer2,
|
||||
LocalBalance: 1_000,
|
||||
RemoteBalance: 9_000,
|
||||
Capacity: 10_000,
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
params.MaxAutoInFlight = 1
|
||||
params.FeeLimit = NewFeePortion(500000)
|
||||
params.LoopInSource = LoopInSourceStaticAddress
|
||||
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
|
||||
chanID1: chanRule,
|
||||
}
|
||||
params.PeerRules = map[route.Vertex]*SwapRule{
|
||||
peer2: {
|
||||
ThresholdRule: NewThresholdRule(0, 50),
|
||||
Type: swap.TypeIn,
|
||||
},
|
||||
}
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
suggestions, err := manager.SuggestSwaps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, suggestions.OutSwaps, 1)
|
||||
require.Empty(t, suggestions.StaticInSwaps)
|
||||
require.Equal(t, ReasonInFlight, suggestions.DisqualifiedPeers[peer2])
|
||||
}
|
||||
|
||||
// TestAutoLoopDispatchesStaticLoopIn verifies that the autoloop execution path
|
||||
// dispatches prepared static loop-ins once they survive final filtering.
|
||||
func TestAutoLoopDispatchesStaticLoopIn(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
|
||||
var (
|
||||
prepareCalls int
|
||||
dispatched *loop.StaticAddressLoopInRequest
|
||||
prepareInitiator string
|
||||
)
|
||||
|
||||
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
|
||||
minAmount, amount btcutil.Amount, label, initiator string,
|
||||
excludedOutpoints []string) (*PreparedStaticLoopIn, error) {
|
||||
|
||||
prepareCalls++
|
||||
prepareInitiator = initiator
|
||||
require.Equal(t, peer1, peer)
|
||||
require.Equal(t, testRestrictions.Minimum, minAmount)
|
||||
require.Equal(t, testRestrictions.Maximum, amount)
|
||||
require.Empty(t, excludedOutpoints)
|
||||
|
||||
return &PreparedStaticLoopIn{
|
||||
Request: loop.StaticAddressLoopInRequest{
|
||||
DepositOutpoints: []string{"static:0"},
|
||||
SelectedAmount: testRestrictions.Maximum,
|
||||
MaxSwapFee: 100,
|
||||
LastHop: &peer,
|
||||
Label: label,
|
||||
Initiator: initiator,
|
||||
},
|
||||
NumDeposits: 1,
|
||||
}, nil
|
||||
}
|
||||
cfg.StaticLoopIn = func(_ context.Context,
|
||||
request *loop.StaticAddressLoopInRequest) (
|
||||
*StaticLoopInDispatchResult, error) {
|
||||
|
||||
requestCopy := *request
|
||||
dispatched = &requestCopy
|
||||
|
||||
return &StaticLoopInDispatchResult{
|
||||
SwapHash: lntypes.Hash{1},
|
||||
}, nil
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{
|
||||
{
|
||||
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
|
||||
PubKeyBytes: peer1,
|
||||
LocalBalance: 0,
|
||||
RemoteBalance: 100_000,
|
||||
Capacity: 100_000,
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.Autoloop = true
|
||||
params.AutoFeeBudget = 100_000
|
||||
params.AutoFeeRefreshPeriod = testBudgetRefresh
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
params.MaxAutoInFlight = 1
|
||||
params.FailureBackOff = time.Hour
|
||||
params.FeeLimit = NewFeePortion(500_000)
|
||||
params.LoopInSource = LoopInSourceStaticAddress
|
||||
params.PeerRules = map[route.Vertex]*SwapRule{
|
||||
peer1: {
|
||||
ThresholdRule: NewThresholdRule(0, 60),
|
||||
Type: swap.TypeIn,
|
||||
},
|
||||
}
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
err := manager.autoloop(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, prepareCalls)
|
||||
require.Equal(t, autoloopSwapInitiator, prepareInitiator)
|
||||
require.NotNil(t, dispatched)
|
||||
require.Equal(t, []string{"static:0"}, dispatched.DepositOutpoints)
|
||||
require.Equal(t, testRestrictions.Maximum, dispatched.SelectedAmount)
|
||||
require.Equal(
|
||||
t, labels.AutoloopLabel(swap.TypeIn), dispatched.Label,
|
||||
)
|
||||
require.Equal(t, autoloopSwapInitiator, dispatched.Initiator)
|
||||
require.NotNil(t, dispatched.LastHop)
|
||||
require.Equal(t, peer1, *dispatched.LastHop)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package loopd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ import (
|
|||
"github.com/lightninglabs/loop/swap"
|
||||
"github.com/lightninglabs/loop/sweepbatcher"
|
||||
"github.com/lightningnetwork/lnd/clock"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/lightningnetwork/lnd/ticker"
|
||||
)
|
||||
|
||||
|
|
@ -161,6 +163,58 @@ func getLiquidityManager(client *loop.Client,
|
|||
return result, nil
|
||||
}
|
||||
|
||||
prepareStaticLoopIn := func(ctx context.Context, peer route.Vertex,
|
||||
minAmount, amount btcutil.Amount, label, initiator string,
|
||||
excludedOutpoints []string) (*liquidity.PreparedStaticLoopIn,
|
||||
error) {
|
||||
|
||||
if staticLoopInManager == nil {
|
||||
return nil, errors.New(
|
||||
"static loop in manager unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
request, numDeposits, hasChange, err :=
|
||||
staticLoopInManager.PrepareAutoloopLoopIn(
|
||||
ctx, peer, minAmount, amount, label,
|
||||
initiator, excludedOutpoints,
|
||||
)
|
||||
if errors.Is(err, loopin.ErrNoAutoloopCandidate) {
|
||||
return nil, liquidity.ErrNoStaticLoopInCandidate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &liquidity.PreparedStaticLoopIn{
|
||||
Request: *request,
|
||||
NumDeposits: numDeposits,
|
||||
HasChange: hasChange,
|
||||
}, nil
|
||||
}
|
||||
|
||||
staticLoopIn := func(ctx context.Context,
|
||||
request *loop.StaticAddressLoopInRequest) (
|
||||
*liquidity.StaticLoopInDispatchResult, error) {
|
||||
|
||||
if staticLoopInManager == nil {
|
||||
return nil, errors.New(
|
||||
"static loop in manager unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
swapInfo, err := staticLoopInManager.DeliverLoopInRequest(
|
||||
ctx, request,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &liquidity.StaticLoopInDispatchResult{
|
||||
SwapHash: swapInfo.SwapHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
mngrCfg := &liquidity.Config{
|
||||
AutoloopTicker: ticker.NewForce(liquidity.DefaultAutoloopTicker),
|
||||
LoopOut: client.LoopOut,
|
||||
|
|
@ -196,6 +250,8 @@ func getLiquidityManager(client *loop.Client,
|
|||
GetLoopOut: client.Store.FetchLoopOutSwap,
|
||||
ListLoopIn: client.Store.FetchLoopInSwaps,
|
||||
ListStaticLoopIn: listStaticLoopIn,
|
||||
PrepareStaticLoopIn: prepareStaticLoopIn,
|
||||
StaticLoopIn: staticLoopIn,
|
||||
LoopInTerms: client.LoopInTerms,
|
||||
LoopOutTerms: client.LoopOutTerms,
|
||||
GetAssetPrice: client.AssetClient.GetAssetPrice,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue