diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 2db419b3..8a32adcd 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -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, ) diff --git a/liquidity/static_loopin.go b/liquidity/static_loopin.go index 338b64b2..bfdcafbc 100644 --- a/liquidity/static_loopin.go +++ b/liquidity/static_loopin.go @@ -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) + } +} diff --git a/liquidity/static_loopin_test.go b/liquidity/static_loopin_test.go index 12b227c4..55860cd5 100644 --- a/liquidity/static_loopin_test.go +++ b/liquidity/static_loopin_test.go @@ -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) +} diff --git a/loopd/utils.go b/loopd/utils.go index 5a98ae97..a73434ca 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -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,