diff --git a/cmd/loop/liquidity.go b/cmd/loop/liquidity.go index 5492f2045..686828e02 100644 --- a/cmd/loop/liquidity.go +++ b/cmd/loop/liquidity.go @@ -340,6 +340,11 @@ var setParamsCommand = &cli.Command{ Usage: "the confirmation target for loop in on-chain " + "htlcs.", }, + &cli.StringFlag{ + Name: "loopinsource", + Usage: "the loop-in source to use for autoloop rules: " + + "wallet or static-address.", + }, &cli.BoolFlag{ Name: "easyautoloop", Usage: "set to true to enable easy autoloop, which " + @@ -555,6 +560,23 @@ func setParams(ctx context.Context, cmd *cli.Command) error { flagSet = true } + if cmd.IsSet("loopinsource") { + switch cmd.String("loopinsource") { + case "wallet": + params.LoopInSource = + looprpc.LoopInSource_LOOP_IN_SOURCE_WALLET + + case "static-address", "static_address", "static": + params.LoopInSource = + looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS + + default: + return fmt.Errorf("unknown loopinsource value") + } + + flagSet = true + } + // If we are setting easy autoloop parameters, we need to ensure that // the asset ID is set, and that we have a valid entry in our params // map. diff --git a/docs/loop.1 b/docs/loop.1 index 7d767cdff..647a6ffe3 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -359,6 +359,9 @@ update the parameters set for the liquidity manager .PP \fB--localbalancesat\fP="": the target size of total local balance in satoshis, used by easy autoloop. (default: 0) +.PP +\fB--loopinsource\fP="": the loop-in source to use for autoloop rules: wallet or static-address. + .PP \fB--maxamt\fP="": the maximum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0) diff --git a/docs/loop.md b/docs/loop.md index 3a847e96c..24a09badd 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -371,6 +371,7 @@ The following flags are supported: | `--minamt="…"` | the minimum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` | | `--maxamt="…"` | the maximum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` | | `--htlc_conf="…"` | the confirmation target for loop in on-chain htlcs | int | `0` | +| `--loopinsource="…"` | the loop-in source to use for autoloop rules: wallet or static-address | string | | `--easyautoloop` | set to true to enable easy autoloop, which will automatically dispatch swaps in order to meet the target local balance | bool | `false` | | `--localbalancesat="…"` | the target size of total local balance in satoshis, used by easy autoloop | uint | `0` | | `--easyautoloop_excludepeer="…"` | list of peer pubkeys (hex) to exclude from easy autoloop channel selection; repeat --easyautoloop_excludepeer for multiple peers | string | `[]` | diff --git a/liquidity/easy_autoloop_exclusions_test.go b/liquidity/easy_autoloop_exclusions_test.go index b70af97c7..e6e236274 100644 --- a/liquidity/easy_autoloop_exclusions_test.go +++ b/liquidity/easy_autoloop_exclusions_test.go @@ -47,10 +47,11 @@ func TestEasyAutoloopExcludedPeers(t *testing.T) { ) // Picking a channel should not pick the excluded peer's channel. - picked := c.manager.pickEasyAutoloopChannel( - []lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions, - nil, nil, 1, + picked, err := c.manager.pickEasyAutoloopChannel( + t.Context(), []lndclient.ChannelInfo{ch1, ch2}, + ¶ms.ClientRestrictions, nil, nil, 1, ) + require.NoError(t, err) require.NotNil(t, picked) require.Equal( t, ch2.ChannelID, picked.ChannelID, @@ -92,10 +93,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) { ) // With exclusion active, peer1 should not be picked. - picked := c.manager.pickEasyAutoloopChannel( - []lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions, - nil, nil, 1, + picked, err := c.manager.pickEasyAutoloopChannel( + t.Context(), []lndclient.ChannelInfo{ch1, ch2}, + ¶ms.ClientRestrictions, nil, nil, 1, ) + require.NoError(t, err) require.NotNil(t, picked) require.Equal(t, ch2.ChannelID, picked.ChannelID) @@ -103,10 +105,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) { // CLI does before sending to the server. c.manager.params.EasyAutoloopExcludedPeers = nil - picked = c.manager.pickEasyAutoloopChannel( - []lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions, - nil, nil, 1, + picked, err = c.manager.pickEasyAutoloopChannel( + t.Context(), []lndclient.ChannelInfo{ch1, ch2}, + ¶ms.ClientRestrictions, nil, nil, 1, ) + require.NoError(t, err) require.NotNil(t, picked) require.Equal( t, ch1.ChannelID, picked.ChannelID, diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index b31e2b003..8a32adcd2 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) @@ -221,6 +234,11 @@ type Config struct { LoopOutTerms func(ctx context.Context, initiator string) (*loop.LoopOutTerms, error) + // ListStaticLoopIn returns all static-address loop-ins that liquidity + // should consider for budget accounting, in-flight limits, and peer + // traffic. + ListStaticLoopIn func(context.Context) ([]*StaticLoopInInfo, error) + // GetAssetPrice returns the price of an asset in satoshis. GetAssetPrice func(ctx context.Context, assetId string, peerPubkey []byte, assetAmt uint64, @@ -497,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 } @@ -574,9 +616,19 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error { return err } + // Load the static loop-in snapshot once for the whole easy-autoloop + // tick so budget and traffic checks cannot drift and do not need to hit + // the store twice. + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return err + } + // Get a summary of our existing swaps so that we can check our autoloop // budget. - summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) + summary := m.checkExistingAutoLoopsWithStatic( + loopOut, loopIn, staticLoopIns, + ) err = m.checkSummaryBudget(summary) if err != nil { @@ -640,9 +692,14 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error { // Start building that swap. builder := newLoopOutBuilder(m.cfg) - channel := m.pickEasyAutoloopChannel( - usableChannels, restrictions, loopOut, loopIn, 0, + channel, err := m.pickEasyAutoloopChannelWithStatic( + usableChannels, restrictions, loopOut, loopIn, + staticLoopIns, 0, ) + if err != nil { + return err + } + if channel == nil { return fmt.Errorf("no eligible channel for easy autoloop") } @@ -721,9 +778,19 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context, return err } + // Load the static loop-in snapshot once for the whole easy-autoloop + // tick so budget and traffic checks cannot drift and do not need to hit + // the store twice. + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return err + } + // Get a summary of our existing swaps so that we can check our autoloop // budget. - summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) + summary := m.checkExistingAutoLoopsWithStatic( + loopOut, loopIn, staticLoopIns, + ) err = m.checkSummaryBudget(summary) if err != nil { @@ -829,9 +896,14 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context, // Start building that swap. builder := newLoopOutBuilder(m.cfg) - channel := m.pickEasyAutoloopChannel( - usableChannels, restrictions, loopOut, loopIn, satsPerAsset, + channel, err := m.pickEasyAutoloopChannelWithStatic( + usableChannels, restrictions, loopOut, loopIn, + staticLoopIns, satsPerAsset, ) + if err != nil { + return err + } + if channel == nil { return fmt.Errorf("no eligible channel for easy autoloop") } @@ -900,6 +972,10 @@ type Suggestions struct { // InSwaps is the set of loop in swaps that we suggest executing. InSwaps []loop.LoopInRequest + // StaticInSwaps is the set of static-address loop-ins that we suggest + // executing. + StaticInSwaps []loop.StaticAddressLoopInRequest + // DisqualifiedChans maps the set of channels that we do not recommend // swaps on to the reason that we did not recommend a swap. DisqualifiedChans map[lnwire.ShortChannelID]Reason @@ -924,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) } @@ -931,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). @@ -948,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) { @@ -990,9 +1087,16 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( return nil, err } + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return nil, err + } + // Get a summary of our existing swaps so that we can check our autoloop // budget. - summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) + summary := m.checkExistingAutoLoopsWithStatic( + loopOut, loopIn, staticLoopIns, + ) err = m.checkSummaryBudget(summary) if err != nil { @@ -1037,11 +1141,13 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( // Get a summary of the channels and peers that are not eligible due // to ongoing swaps. - traffic := m.currentSwapTraffic(loopOut, loopIn) + traffic := m.currentSwapTrafficWithStatic( + loopOut, loopIn, staticLoopIns, + ) var ( - suggestions []swapSuggestion - resp = newSuggestions() + candidates []suggestionCandidate + resp = newSuggestions() ) for peer, balances := range peerChannels { @@ -1064,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 { @@ -1099,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 @@ -1119,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 @@ -1129,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 @@ -1139,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. @@ -1148,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 } @@ -1175,18 +1314,85 @@ 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) } } return resp, nil } +// loadStaticLoopIns retrieves the static loop-ins that liquidity uses for +// shared accounting and traffic calculations. +func (m *Manager) loadStaticLoopIns(ctx context.Context) ( + []*StaticLoopInInfo, error) { + + if m.cfg.ListStaticLoopIn == nil { + return nil, nil + } + + 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 @@ -1230,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, ) @@ -1308,12 +1531,28 @@ func (e *existingAutoLoopSummary) totalFees() btcutil.Amount { } // checkExistingAutoLoops calculates the total amount that has been spent by -// automatically dispatched swaps that have completed, and the worst-case fee -// total for our set of ongoing, automatically dispatched swaps as well as a -// current in-flight count. -func (m *Manager) checkExistingAutoLoops(_ context.Context, +// automatically dispatched swaps that have completed, the worst-case fee total +// for our set of ongoing automatically dispatched swaps, and the current +// in-flight count. +func (m *Manager) checkExistingAutoLoops(ctx context.Context, loopOuts []*loopdb.LoopOut, - loopIns []*loopdb.LoopIn) *existingAutoLoopSummary { + loopIns []*loopdb.LoopIn) (*existingAutoLoopSummary, error) { + + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return nil, err + } + + return m.checkExistingAutoLoopsWithStatic( + loopOuts, loopIns, staticLoopIns, + ), nil +} + +// checkExistingAutoLoopsWithStatic calculates our autoloop budget summary from +// the provided swap snapshots. +func (m *Manager) checkExistingAutoLoopsWithStatic( + loopOuts []*loopdb.LoopOut, loopIns []*loopdb.LoopIn, + staticLoopIns []*StaticLoopInInfo) *existingAutoLoopSummary { var summary existingAutoLoopSummary @@ -1370,14 +1609,72 @@ func (m *Manager) checkExistingAutoLoops(_ context.Context, } } + for _, in := range staticLoopIns { + if !isAutoloopLabel(in.Label) { + continue + } + + inBudget := !in.LastUpdateTime.Before( + m.params.AutoloopBudgetLastRefresh, + ) + + switch { + case in.Pending: + summary.inFlightCount++ + summary.pendingFees += staticLoopInWorstCaseFees( + in.NumDeposits, in.HasChange, in.QuotedSwapFee, + in.HtlcTxFeeRate, defaultLoopInSweepFee, + ) + + case !inBudget: + continue + + case in.Failed: + // Static loop-in failure accounting stays pessimistic + // here. Once the swap is terminal we no longer know + // from liquidity's persisted view whether the timeout + // path actually confirmed, so we reserve the same + // worst-case fee shape we used while the swap was in + // flight. + // TODO: Persist real static-address swap costs, + // similar to loopdb.SwapCost, and use that exact + // terminal value here instead of the pessimistic + // worst-case estimate. + summary.spentFees += staticLoopInWorstCaseFees( + in.NumDeposits, in.HasChange, in.QuotedSwapFee, + in.HtlcTxFeeRate, defaultLoopInSweepFee, + ) + + default: + summary.spentFees += in.QuotedSwapFee + } + } + return &summary } // currentSwapTraffic examines our existing swaps and returns a summary of the // current activity which can be used to determine whether we should perform // any swaps. -func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut, - loopIn []*loopdb.LoopIn) *swapTraffic { +func (m *Manager) currentSwapTraffic(ctx context.Context, + loopOut []*loopdb.LoopOut, + loopIn []*loopdb.LoopIn) (*swapTraffic, error) { + + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return nil, err + } + + return m.currentSwapTrafficWithStatic( + loopOut, loopIn, staticLoopIns, + ), nil +} + +// currentSwapTrafficWithStatic builds the shared traffic view from the +// provided swap snapshots. +func (m *Manager) currentSwapTrafficWithStatic(loopOut []*loopdb.LoopOut, + loopIn []*loopdb.LoopIn, + staticLoopIns []*StaticLoopInInfo) *swapTraffic { traffic := newSwapTraffic() @@ -1408,9 +1705,7 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut, if failedAt.After(failureCutoff) { for _, id := range chanSet { - chanID := lnwire.NewShortChanIDFromInt( - id, - ) + chanID := lnwire.NewShortChanIDFromInt(id) traffic.failedLoopOut[chanID] = failedAt } @@ -1464,6 +1759,22 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut, } } + for _, in := range staticLoopIns { + if in.LastHop == nil { + continue + } + + pubkey := *in.LastHop + + switch { + case in.Pending && in.BlocksLoopIn: + traffic.ongoingLoopIn[pubkey] = true + + case in.Failed && in.LastUpdateTime.After(failureCutoff): + traffic.failedLoopIn[pubkey] = in.LastUpdateTime + } + } + return traffic } @@ -1651,11 +1962,34 @@ func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash, // This function prioritizes channels with high local balance but also consults // previous failures and ongoing swaps to avoid temporary channel failures or // swap conflicts. -func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo, - restrictions *Restrictions, loopOut []*loopdb.LoopOut, - loopIn []*loopdb.LoopIn, satsPerAsset float64) *lndclient.ChannelInfo { +func (m *Manager) pickEasyAutoloopChannel(ctx context.Context, + channels []lndclient.ChannelInfo, restrictions *Restrictions, + loopOut []*loopdb.LoopOut, loopIn []*loopdb.LoopIn, + satsPerAsset float64) (*lndclient.ChannelInfo, error) { - traffic := m.currentSwapTraffic(loopOut, loopIn) + staticLoopIns, err := m.loadStaticLoopIns(ctx) + if err != nil { + return nil, err + } + + return m.pickEasyAutoloopChannelWithStatic( + channels, restrictions, loopOut, loopIn, staticLoopIns, + satsPerAsset, + ) +} + +// pickEasyAutoloopChannelWithStatic picks an easy-autoloop channel using a +// shared static loop-in snapshot so callers can reuse one store load across +// budget and traffic checks within the same autoloop tick. +func (m *Manager) pickEasyAutoloopChannelWithStatic( + channels []lndclient.ChannelInfo, restrictions *Restrictions, + loopOut []*loopdb.LoopOut, loopIn []*loopdb.LoopIn, + staticLoopIns []*StaticLoopInInfo, + satsPerAsset float64) (*lndclient.ChannelInfo, error) { + + traffic := m.currentSwapTrafficWithStatic( + loopOut, loopIn, staticLoopIns, + ) // Sort the candidate channels based on descending local balance. We // want to prioritize picking a channel with the highest possible local @@ -1722,13 +2056,13 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo, "minimum is %v, skipping remaining channels", channel.ChannelID, channel.LocalBalance, restrictions.Minimum) - return nil + return nil, nil } - return &channel + return &channel, nil } - return nil + return nil, nil } func (m *Manager) numActiveStickyLoops() int { diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index 00f2cb58f..77744733b 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -2,6 +2,8 @@ package liquidity import ( "context" + "encoding/hex" + "encoding/json" "testing" "time" @@ -13,6 +15,7 @@ import ( clientrpc "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" + "github.com/lightninglabs/taproot-assets/rfqmsg" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" @@ -169,6 +172,125 @@ func newTestConfig() (*Config, *test.LndMockServices) { }, lnd } +// TestSuggestSwapsLoadsStaticLoopInsOnce verifies that SuggestSwaps reuses the +// same static loop-in snapshot for budget and traffic checks within a single +// planner pass. +func TestSuggestSwapsLoadsStaticLoopInsOnce(t *testing.T) { + ctx := t.Context() + + cfg, lnd := newTestConfig() + staticCalls := 0 + cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) { + staticCalls++ + + return nil, nil + } + + lnd.Channels = []lndclient.ChannelInfo{channel1} + + manager := NewManager(cfg) + params := manager.GetParameters() + params.AutoloopBudgetLastRefresh = testBudgetStart + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ + chanID1: chanRule, + } + require.NoError(t, manager.setParameters(ctx, params)) + + _, err := manager.SuggestSwaps(ctx) + require.NoError(t, err) + require.Equal(t, 1, staticCalls) +} + +// TestEasyAutoloopLoadsStaticLoopInsOnce verifies that easy autoloop reuses +// the same static loop-in snapshot for budget and traffic checks within one +// tick. +func TestEasyAutoloopLoadsStaticLoopInsOnce(t *testing.T) { + ctx := t.Context() + + cfg, lnd := newTestConfig() + staticCalls := 0 + cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) { + staticCalls++ + + return nil, nil + } + + lnd.Channels = []lndclient.ChannelInfo{ + { + ChannelID: chanID1.ToUint64(), + PubKeyBytes: peer1, + LocalBalance: 90_000, + Capacity: 100_000, + }, + } + + manager := NewManager(cfg) + params := manager.GetParameters() + params.AutoloopBudgetLastRefresh = testBudgetStart + params.EasyAutoloop = true + params.EasyAutoloopTarget = 50_000 + require.NoError(t, manager.setParameters(ctx, params)) + + err := manager.dispatchBestEasyAutoloopSwap(ctx) + require.EqualError(t, err, "no eligible channel for easy autoloop") + require.Equal(t, 1, staticCalls) +} + +// TestEasyAssetAutoloopLoadsStaticLoopInsOnce verifies that asset easy +// autoloop reuses one static loop-in snapshot across budget and traffic +// checks within the same tick. +func TestEasyAssetAutoloopLoadsStaticLoopInsOnce(t *testing.T) { + ctx := t.Context() + + assetID := [32]byte{1} + assetStr := hex.EncodeToString(assetID[:]) + + customChanData := rfqmsg.JsonAssetChannel{ + FundingAssets: []rfqmsg.JsonAssetUtxo{ + { + AssetGenesis: rfqmsg.JsonAssetGenesis{ + AssetID: assetStr, + }, + }, + }, + LocalBalance: 90_000, + RemoteBalance: 0, + Capacity: 100_000, + } + customChanDataBytes, err := json.Marshal(customChanData) + require.NoError(t, err) + + cfg, lnd := newTestConfig() + staticCalls := 0 + cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) { + staticCalls++ + + return nil, nil + } + cfg.GetAssetPrice = func(context.Context, string, []byte, uint64, + btcutil.Amount) (btcutil.Amount, error) { + + return 10_000, nil + } + + lnd.Channels = []lndclient.ChannelInfo{ + { + ChannelID: chanID1.ToUint64(), + PubKeyBytes: peer1, + CustomChannelData: customChanDataBytes, + }, + } + + manager := NewManager(cfg) + params := manager.GetParameters() + params.AutoloopBudgetLastRefresh = testBudgetStart + require.NoError(t, manager.setParameters(ctx, params)) + + err = manager.dispatchBestAssetEasyAutoloopSwap(ctx, assetStr, 50_000) + require.EqualError(t, err, "no eligible channel for easy autoloop") + require.Equal(t, 1, staticCalls) +} + // testPPMFees calculates the split of fees between prepay and swap invoice // for the swap amount and ppm, relying on the test quote. func testPPMFees(ppm uint64, quote *loop.LoopOutQuote, @@ -262,11 +384,12 @@ func TestPersistParams(t *testing.T) { FeePpm: 100, AutoMaxInFlight: 10, HtlcConfTarget: 2, + LoopInSource: clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, } cfg, _ := newTestConfig() manager := NewManager(cfg) - ctxb := context.Background() + ctx := t.Context() var paramsBytes []byte @@ -276,7 +399,7 @@ func TestPersistParams(t *testing.T) { } // Test the nil params is returned. - req, err := manager.loadParams(ctxb) + req, err := manager.loadParams(ctx) require.Nil(t, req) require.NoError(t, err) @@ -289,17 +412,18 @@ func TestPersistParams(t *testing.T) { } // Test save the message. - err = manager.saveParams(ctxb, rpcParams) + err = manager.saveParams(ctx, rpcParams) require.NoError(t, err) // Test the nil params is returned. - req, err = manager.loadParams(ctxb) + req, err = manager.loadParams(ctx) require.NoError(t, err) // Check the specified fields are set as expected. require.Equal(t, rpcParams.FeePpm, req.FeePpm) require.Equal(t, rpcParams.AutoMaxInFlight, req.AutoMaxInFlight) require.Equal(t, rpcParams.HtlcConfTarget, req.HtlcConfTarget) + require.Equal(t, rpcParams.LoopInSource, req.LoopInSource) // Check the unspecified fields are using empty values. require.False(t, req.Autoloop) @@ -308,8 +432,12 @@ func TestPersistParams(t *testing.T) { // Finally, check the loaded request can be used to set params without // error. - err = manager.SetParameters(context.Background(), req) + err = manager.SetParameters(ctx, req) require.NoError(t, err) + require.Equal( + t, LoopInSourceStaticAddress, + manager.GetParameters().LoopInSource, + ) } // TestRestrictedSuggestions tests getting of swap suggestions when we have @@ -2032,12 +2160,14 @@ func TestCurrentTraffic(t *testing.T) { for _, testCase := range tests { cfg, _ := newTestConfig() m := NewManager(cfg) + ctx := t.Context() params := m.GetParameters() params.FailureBackOff = backoff - require.NoError(t, m.setParameters(context.Background(), params)) + require.NoError(t, m.setParameters(ctx, params)) - actual := m.currentSwapTraffic(testCase.loopOut, testCase.loopIn) + actual, err := m.currentSwapTraffic(ctx, testCase.loopOut, testCase.loopIn) + require.NoError(t, err) require.Equal(t, testCase.expected, actual) } } diff --git a/liquidity/loopin_source.go b/liquidity/loopin_source.go new file mode 100644 index 000000000..14f698d60 --- /dev/null +++ b/liquidity/loopin_source.go @@ -0,0 +1,30 @@ +package liquidity + +import "fmt" + +// LoopInSource identifies the funding source that autoloop should use for loop +// in swaps. +type LoopInSource uint8 + +const ( + // LoopInSourceWallet uses the legacy wallet-funded loop-in flow. + LoopInSourceWallet LoopInSource = iota + + // LoopInSourceStaticAddress uses deposited static-address funds for + // loop-ins and does not fall back to wallet-funded loop-ins. + LoopInSourceStaticAddress +) + +// String returns a human-readable representation of the source. +func (s LoopInSource) String() string { + switch s { + case LoopInSourceWallet: + return "wallet" + + case LoopInSourceStaticAddress: + return "static-address" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} diff --git a/liquidity/parameters.go b/liquidity/parameters.go index 7032ff83a..44c555db6 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -32,6 +32,7 @@ var ( HtlcConfTarget: defaultHtlcConfTarget, FeeLimit: defaultFeePortion(), FastSwapPublication: true, + LoopInSource: LoopInSourceWallet, } ) @@ -128,6 +129,10 @@ type Parameters struct { // swaps. If set to true, the deadline is set to immediate publication. // If set to false, the deadline is set to 30 minutes. FastSwapPublication bool + + // LoopInSource controls which funding source autoloop uses for loop-in + // rules. + LoopInSource LoopInSource } // AssetParams define the asset specific autoloop parameters. @@ -160,11 +165,13 @@ func (p Parameters) String() string { return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ "sweep conf target: %v, htlc conf target: %v,fees: %v, "+ "auto budget: %v, budget refresh: %v, max auto in flight: %v, "+ - "minimum swap size=%v, maximum swap size=%v", + "minimum swap size: %v, maximum swap size: %v, "+ + "loop in source: %v", strings.Join(ruleList, ","), p.FailureBackOff, p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit, p.AutoFeeBudget, p.AutoFeeRefreshPeriod, p.MaxAutoInFlight, - p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) + p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum, + p.LoopInSource) } // haveRules returns a boolean indicating whether we have any rules configured. @@ -260,6 +267,13 @@ func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, return ErrZeroInFlight } + switch p.LoopInSource { + case LoopInSourceWallet, LoopInSourceStaticAddress: + + default: + return fmt.Errorf("unknown loop in source: %v", p.LoopInSource) + } + // Destination address and account cannot be set at the same time. if p.DestAddr != nil && len(p.DestAddr.String()) > 0 && len(p.Account) > 0 { @@ -409,6 +423,37 @@ func rpcToRule(rule *clientrpc.LiquidityRule) (*SwapRule, error) { } } +// rpcToLoopInSource converts the rpc loop-in source enum to the internal +// liquidity enum. +func rpcToLoopInSource(source clientrpc.LoopInSource) (LoopInSource, error) { + switch source { + case clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET: + return LoopInSourceWallet, nil + + case clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS: + return LoopInSourceStaticAddress, nil + + default: + return 0, fmt.Errorf("unknown rpc loop in source: %v", source) + } +} + +// loopInSourceToRPC converts the internal loop-in source enum to its rpc +// representation. +func loopInSourceToRPC(source LoopInSource) (clientrpc.LoopInSource, error) { + switch source { + case LoopInSourceWallet: + return clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET, nil + + case LoopInSourceStaticAddress: + return clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, + nil + + default: + return 0, fmt.Errorf("unknown loop in source: %v", source) + } +} + // RpcToParameters takes a `LiquidityParameters` and creates a `Parameters` // from it. func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, @@ -446,6 +491,11 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, } } + loopInSource, err := rpcToLoopInSource(req.LoopInSource) + if err != nil { + return nil, err + } + params := &Parameters{ FeeLimit: feeLimit, SweepConfTarget: req.SweepConfTarget, @@ -477,6 +527,7 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, ), AssetAutoloopParams: easyAssetParams, FastSwapPublication: req.FastSwapPublication, + LoopInSource: loopInSource, } if req.AutoloopBudgetRefreshPeriodSec != 0 { @@ -592,6 +643,11 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, } } + loopInSource, err := loopInSourceToRPC(cfg.LoopInSource) + if err != nil { + return nil, err + } + rpcCfg := &clientrpc.LiquidityParameters{ SweepConfTarget: cfg.SweepConfTarget, FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()), @@ -621,6 +677,7 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, AccountAddrType: addrType, EasyAssetParams: easyAssetMap, FastSwapPublication: cfg.FastSwapPublication, + LoopInSource: loopInSource, } // Set excluded peers for easy autoloop. rpcCfg.EasyAutoloopExcludedPeers = make( diff --git a/liquidity/reasons.go b/liquidity/reasons.go index a73f9000a..a186e6f85 100644 --- a/liquidity/reasons.go +++ b/liquidity/reasons.go @@ -73,6 +73,11 @@ const ( // ReasonCustomChannelData indicates that the channel is not standard // and should not be used for swaps. ReasonCustomChannelData + + // ReasonStaticLoopInNoCandidate indicates that static loop-in + // autoloop was selected, but no full-deposit static candidate was + // available for the target peer. + ReasonStaticLoopInNoCandidate ) // String returns a string representation of a reason. @@ -123,6 +128,12 @@ func (r Reason) String() string { case ReasonLoopInUnreachable: return "loop in unreachable" + case ReasonCustomChannelData: + return "custom channel data" + + case ReasonStaticLoopInNoCandidate: + return "no static loop-in candidate" + default: return "unknown" } diff --git a/liquidity/static_loopin.go b/liquidity/static_loopin.go new file mode 100644 index 000000000..2bb62f83f --- /dev/null +++ b/liquidity/static_loopin.go @@ -0,0 +1,280 @@ +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 { + // Label identifies whether the swap belongs to autoloop. + Label string + + // QuotedSwapFee is the quoted server fee for the swap. + QuotedSwapFee btcutil.Amount + + // HtlcTxFeeRate is the stored HTLC transaction fee rate for the swap's + // timeout path. This is only known once the static loop-in has been + // initiated and the server has proposed concrete HTLC transactions. + HtlcTxFeeRate chainfee.SatPerKWeight + + // LastHop identifies the target peer when the swap is peer-restricted. + LastHop *route.Vertex + + // LastUpdateTime is the timestamp of the latest persisted state update. + LastUpdateTime time.Time + + // Pending indicates whether the swap is still in flight and therefore + // needs worst-case fee reservation in the current budget window. + Pending bool + + // Failed indicates whether the swap reached a terminal failure state. + // Liquidity uses this to apply conservative fee accounting and recent + // failure backoff for the peer. + Failed bool + + // BlocksLoopIn indicates whether the swap should currently block new + // loop-in suggestions for its peer. Static swaps stop blocking once the + // off-chain payment has been received. + BlocksLoopIn bool + + // NumDeposits is the number of deposits locked into the swap. + NumDeposits int + + // HasChange indicates whether the swap selected less than the total + // value of its deposits and therefore produced change. + 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, + swapFee btcutil.Amount, htlcFeeRate, + timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount { + + successFee := swapFee + + timeoutFee := staticLoopInOnchainFee( + numDeposits, hasChange, htlcFeeRate, timeoutSweepFeeRate, + ) + + return max(timeoutFee, successFee) +} + +// staticLoopInOnchainFee estimates the fee for the server-published HTLC +// transaction and client sweep transaction. +func staticLoopInOnchainFee(numDeposits int, hasChange bool, htlcFeeRate, + timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount { + + htlcFee := htlcFeeRate.FeeForWeight( + staticLoopInHtlcWeight(numDeposits, hasChange), + ) + + sweepFee := loopInSweepFee(timeoutSweepFeeRate) + + return htlcFee + sweepFee +} + +// staticLoopInHtlcWeight returns the HTLC transaction weight for a static loop +// in with the given number of deposits. +func staticLoopInHtlcWeight(numDeposits int, + hasChange bool) lntypes.WeightUnit { + + var estimator input.TxWeightEstimator + + for range numDeposits { + estimator.AddTaprootKeySpendInput(txscript.SigHashDefault) + } + + estimator.AddP2WSHOutput() + + if hasChange { + estimator.AddP2TROutput() + } + + 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 new file mode 100644 index 000000000..d843d5a1c --- /dev/null +++ b/liquidity/static_loopin_test.go @@ -0,0 +1,472 @@ +package liquidity + +import ( + "context" + "testing" + "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" +) + +// TestStaticLoopInHtlcWeight verifies the exact HTLC transaction weight for +// the supported deposit-count and change-shape combinations. +func TestStaticLoopInHtlcWeight(t *testing.T) { + testCases := []struct { + name string + numDeposits int + hasChange bool + expected int64 + }{ + { + name: "zero deposits no change", + numDeposits: 0, + expected: 212, + }, + { + name: "zero deposits with change", + numDeposits: 0, + hasChange: true, + expected: 384, + }, + { + name: "single deposit no change", + numDeposits: 1, + expected: 444, + }, + { + name: "multiple deposits with change", + numDeposits: 3, + hasChange: true, + expected: 1076, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + weight := staticLoopInHtlcWeight( + testCase.numDeposits, testCase.hasChange, + ) + + require.Equal(t, testCase.expected, int64(weight)) + }) + } +} + +// TestStaticLoopInOnchainFee verifies that the HTLC publish fee and timeout +// sweep fee are combined correctly across the supported shapes. +func TestStaticLoopInOnchainFee(t *testing.T) { + testCases := []struct { + name string + numDeposits int + hasChange bool + htlcFeeRate chainfee.SatPerKWeight + timeoutSweepFeeRate chainfee.SatPerKWeight + expected btcutil.Amount + }{ + { + name: "zero fee rates", + expected: 0, + }, + { + name: "single deposit without change", + numDeposits: 1, + htlcFeeRate: chainfee.SatPerKWeight(1_200), + timeoutSweepFeeRate: chainfee.SatPerKWeight(800), + expected: 884, + }, + { + name: "multiple deposits with change", + numDeposits: 3, + hasChange: true, + htlcFeeRate: chainfee.SatPerKWeight(2_500), + timeoutSweepFeeRate: chainfee.SatPerKWeight(1_700), + expected: 3439, + }, + { + name: "sweep fee only", + numDeposits: 2, + htlcFeeRate: 0, + timeoutSweepFeeRate: chainfee.SatPerKWeight(1_100), + expected: 485, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + onchainFee := staticLoopInOnchainFee( + testCase.numDeposits, testCase.hasChange, + testCase.htlcFeeRate, + testCase.timeoutSweepFeeRate, + ) + + require.Equal(t, testCase.expected, onchainFee) + }) + } +} + +// TestStaticLoopInWorstCaseFees verifies that the helper chooses the larger +// of the cooperative success fee and timeout-path fee. +func TestStaticLoopInWorstCaseFees(t *testing.T) { + testCases := []struct { + name string + numDeposits int + hasChange bool + swapFee btcutil.Amount + htlcFeeRate chainfee.SatPerKWeight + timeoutSweepFeeRate chainfee.SatPerKWeight + expected btcutil.Amount + }{ + { + name: "all fees zero", + expected: 0, + }, + { + name: "returns success fee when larger", + numDeposits: 1, + swapFee: 5_000, + htlcFeeRate: chainfee.SatPerKWeight(800), + timeoutSweepFeeRate: chainfee.SatPerKWeight(700), + expected: 5_000, + }, + { + name: "returns timeout fee when larger", + numDeposits: 2, + hasChange: true, + swapFee: 1_000, + htlcFeeRate: chainfee.SatPerKWeight(5_000), + timeoutSweepFeeRate: chainfee.SatPerKWeight(3_000), + expected: 5553, + }, + { + name: "returns equal fee when paths match", + numDeposits: 1, + swapFee: 884, + htlcFeeRate: chainfee.SatPerKWeight(1_200), + timeoutSweepFeeRate: chainfee.SatPerKWeight(800), + expected: 884, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + require.Equal( + t, testCase.expected, + staticLoopInWorstCaseFees( + testCase.numDeposits, + testCase.hasChange, + testCase.swapFee, testCase.htlcFeeRate, + testCase.timeoutSweepFeeRate, + ), + ) + }) + } +} + +// TestCheckExistingAutoLoopsStatic verifies that static autoloops contribute +// to the same budget summary as legacy autoloops. +func TestCheckExistingAutoLoopsStatic(t *testing.T) { + ctx := t.Context() + + sampleStaticLoopIns := []*StaticLoopInInfo{ + { + Label: labels.AutoloopLabel(swap.TypeIn), + QuotedSwapFee: 50, + LastUpdateTime: testTime, + }, + { + Label: labels.AutoloopLabel(swap.TypeIn), + QuotedSwapFee: 80, + HtlcTxFeeRate: chainfee.SatPerKWeight(900), + LastUpdateTime: testTime, + Pending: true, + NumDeposits: 2, + }, + { + Label: labels.AutoloopLabel(swap.TypeIn), + QuotedSwapFee: 70, + HtlcTxFeeRate: chainfee.SatPerKWeight(800), + LastUpdateTime: testTime, + Failed: true, + NumDeposits: 1, + }, + } + + cfg, _ := newTestConfig() + cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, + error) { + + return sampleStaticLoopIns, nil + } + + manager := NewManager(cfg) + params := manager.GetParameters() + params.AutoloopBudgetLastRefresh = testBudgetStart + require.NoError(t, manager.setParameters(ctx, params)) + + summary, err := manager.checkExistingAutoLoops(ctx, nil, nil) + require.NoError(t, err) + + require.Equal(t, 1, summary.inFlightCount) + require.Equal( + t, + btcutil.Amount(50)+staticLoopInWorstCaseFees( + 1, false, 70, chainfee.SatPerKWeight(800), + defaultLoopInSweepFee, + ), + summary.spentFees, + ) + require.Equal( + t, + staticLoopInWorstCaseFees( + 2, false, 80, chainfee.SatPerKWeight(900), + defaultLoopInSweepFee, + ), + summary.pendingFees, + ) +} + +// TestCurrentSwapTrafficStatic verifies that static loop-ins contribute peer +// blocking and failure backoff information to the shared traffic summary. +func TestCurrentSwapTrafficStatic(t *testing.T) { + ctx := t.Context() + + cfg, _ := newTestConfig() + cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, + error) { + + return []*StaticLoopInInfo{ + { + LastHop: &peer1, + LastUpdateTime: testTime, + Pending: true, + BlocksLoopIn: true, + }, + { + LastHop: &peer2, + LastUpdateTime: testTime, + Failed: true, + }, + { + LastHop: &route.Vertex{3}, + LastUpdateTime: testTime, + Pending: true, + BlocksLoopIn: false, + }, + }, nil + } + + manager := NewManager(cfg) + params := manager.GetParameters() + params.FailureBackOff = time.Hour + require.NoError(t, manager.setParameters(ctx, params)) + + traffic, err := manager.currentSwapTraffic(ctx, nil, nil) + require.NoError(t, err) + + require.True(t, traffic.ongoingLoopIn[peer1]) + 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/daemon.go b/loopd/daemon.go index 880e19621..9b00f4e07 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -734,12 +734,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error { ) } + liquidityMgr := getLiquidityManager( + swapClient, staticLoopInManager, + ) + // Now finally fully initialize the swap client RPC server instance. d.swapClientServer = swapClientServer{ config: d.cfg, network: lndclient.Network(d.cfg.Network), impl: swapClient, - liquidityMgr: getLiquidityManager(swapClient), + liquidityMgr: liquidityMgr, lnd: &d.lnd.LndServices, swaps: make(map[lntypes.Hash]loop.SwapInfo), subscribers: make(map[int]chan<- any), diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 62187d3e5..39cf4a71c 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1426,6 +1426,10 @@ func (s *swapClientServer) SuggestSwaps(ctx context.Context, LoopIn: make( []*looprpc.LoopInRequest, len(suggestions.InSwaps), ), + StaticLoopIn: make( + []*looprpc.StaticAddressLoopInRequest, + len(suggestions.StaticInSwaps), + ), } for i, swap := range suggestions.OutSwaps { @@ -1456,6 +1460,24 @@ func (s *swapClientServer) SuggestSwaps(ctx context.Context, resp.LoopIn[i] = loopIn } + for i, swap := range suggestions.StaticInSwaps { + request := &looprpc.StaticAddressLoopInRequest{ + Outpoints: swap.DepositOutpoints, + MaxSwapFeeSatoshis: int64(swap.MaxSwapFee), + Label: swap.Label, + Initiator: swap.Initiator, + PaymentTimeoutSeconds: swap.PaymentTimeoutSeconds, + Amount: int64(swap.SelectedAmount), + Fast: swap.Fast, + } + + if swap.LastHop != nil { + request.LastHop = swap.LastHop[:] + } + + resp.StaticLoopIn[i] = request + } + for id, reason := range suggestions.DisqualifiedChans { autoloopReason, err := rpcAutoloopReason(reason) if err != nil { @@ -2105,6 +2127,13 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, req.LastHop = &lastHop } + // External callers must not be able to use reserved autoloop labels. + // Internal autoloop dispatch bypasses this RPC and can still use the + // reserved labels needed to attribute automated swaps correctly. + if err := labels.Validate(req.Label); err != nil { + return nil, fmt.Errorf("invalid label: %w", err) + } + loopIn, err := s.staticLoopInManager.DeliverLoopInRequest(ctx, req) if err != nil { return nil, err @@ -2409,6 +2438,13 @@ func rpcAutoloopReason(reason liquidity.Reason) (looprpc.AutoReason, error) { case liquidity.ReasonFeePPMInsufficient: return looprpc.AutoReason_AUTO_REASON_SWAP_FEE, nil + case liquidity.ReasonStaticLoopInNoCandidate: + return looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE, + nil + + case liquidity.ReasonCustomChannelData: + return looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, nil + default: return 0, fmt.Errorf("unknown autoloop reason: %v", reason) } diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index a3f29443f..65494664f 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -15,6 +15,7 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" @@ -261,6 +262,48 @@ func TestValidateLoopInRequest(t *testing.T) { } } +// TestStaticAddressLoopInRejectsReservedLabel verifies that external static +// loop-in requests still reject reserved autoloop labels at the RPC boundary. +func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) { + logger := btclog.NewSLogger( + btclog.NewDefaultHandler(os.Stdout), + ) + setLogger(logger.SubSystem(Subsystem)) + + server := &swapClientServer{} + + _, err := server.StaticAddressLoopIn( + t.Context(), &looprpc.StaticAddressLoopInRequest{ + Label: labels.AutoloopLabel(swap.TypeIn), + }, + ) + require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) +} + +// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner +// reason is exposed over rpc. +func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) { + reason, err := rpcAutoloopReason( + liquidity.ReasonStaticLoopInNoCandidate, + ) + require.NoError(t, err) + require.Equal( + t, + looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE, + reason, + ) +} + +// TestRPCAutoloopReasonCustomChannelData verifies that custom-channel +// disqualifications are exposed over rpc instead of failing the whole dry run. +func TestRPCAutoloopReasonCustomChannelData(t *testing.T) { + reason, err := rpcAutoloopReason(liquidity.ReasonCustomChannelData) + require.NoError(t, err) + require.Equal( + t, looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, reason, + ) +} + // TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the // daemon shutdown. func TestSwapClientServerStopDaemon(t *testing.T) { diff --git a/loopd/utils.go b/loopd/utils.go index 9b439ac5a..a73434caa 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -2,7 +2,9 @@ package loopd import ( "context" + "errors" "fmt" + "slices" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -12,9 +14,11 @@ import ( "github.com/lightninglabs/loop/assets" "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/staticaddr/loopin" "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" ) @@ -115,7 +119,102 @@ func openDatabase(cfg *Config, chainParams *chaincfg.Params) (loopdb.SwapStore, return db, &baseDb, nil } -func getLiquidityManager(client *loop.Client) *liquidity.Manager { +func getLiquidityManager(client *loop.Client, + staticLoopInManager *loopin.Manager) *liquidity.Manager { + + listStaticLoopIn := func( + ctx context.Context) ([]*liquidity.StaticLoopInInfo, error) { + + if staticLoopInManager == nil { + return nil, nil + } + + swaps, err := staticLoopInManager.GetAllSwaps(ctx) + if err != nil { + return nil, err + } + + result := make( + []*liquidity.StaticLoopInInfo, 0, len(swaps), + ) + for _, staticSwap := range swaps { + state := staticSwap.GetState() + pending := slices.Contains(loopin.PendingStates, state) + failed := state == loopin.Failed || + state == loopin.HtlcTimeoutSwept + + result = append(result, &liquidity.StaticLoopInInfo{ + Label: staticSwap.Label, + QuotedSwapFee: staticSwap.QuotedSwapFee, + HtlcTxFeeRate: staticSwap.HtlcTxFeeRate, + LastHop: staticSwap.LastHopVertex(), + LastUpdateTime: staticSwap.LastUpdateTime, + Pending: pending, + Failed: failed, + BlocksLoopIn: pending && + state != loopin.PaymentReceived, + NumDeposits: len(staticSwap.Deposits), + HasChange: staticSwap.SelectedAmount > 0 && + staticSwap.SelectedAmount < + staticSwap.TotalDepositAmount(), + }) + } + + 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, @@ -150,6 +249,9 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager { ListLoopOut: client.Store.FetchLoopOutSwaps, GetLoopOut: client.Store.FetchLoopOutSwap, ListLoopIn: client.Store.FetchLoopInSwaps, + ListStaticLoopIn: listStaticLoopIn, + PrepareStaticLoopIn: prepareStaticLoopIn, + StaticLoopIn: staticLoopIn, LoopInTerms: client.LoopInTerms, LoopOutTerms: client.LoopOutTerms, GetAssetPrice: client.AssetClient.GetAssetPrice, diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 35ae71c82..d0cf44f17 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -288,6 +288,54 @@ func (FailureReason) EnumDescriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{3} } +type LoopInSource int32 + +const ( + // Use the legacy wallet-funded loop-in flow. + LoopInSource_LOOP_IN_SOURCE_WALLET LoopInSource = 0 + // Use deposited static-address funds for loop-in autoloops. + LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS LoopInSource = 1 +) + +// Enum value maps for LoopInSource. +var ( + LoopInSource_name = map[int32]string{ + 0: "LOOP_IN_SOURCE_WALLET", + 1: "LOOP_IN_SOURCE_STATIC_ADDRESS", + } + LoopInSource_value = map[string]int32{ + "LOOP_IN_SOURCE_WALLET": 0, + "LOOP_IN_SOURCE_STATIC_ADDRESS": 1, + } +) + +func (x LoopInSource) Enum() *LoopInSource { + p := new(LoopInSource) + *p = x + return p +} + +func (x LoopInSource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LoopInSource) Descriptor() protoreflect.EnumDescriptor { + return file_client_proto_enumTypes[4].Descriptor() +} + +func (LoopInSource) Type() protoreflect.EnumType { + return &file_client_proto_enumTypes[4] +} + +func (x LoopInSource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use LoopInSource.Descriptor instead. +func (LoopInSource) EnumDescriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{4} +} + type LiquidityRuleType int32 const ( @@ -318,11 +366,11 @@ func (x LiquidityRuleType) String() string { } func (LiquidityRuleType) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[4].Descriptor() + return file_client_proto_enumTypes[5].Descriptor() } func (LiquidityRuleType) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[4] + return &file_client_proto_enumTypes[5] } func (x LiquidityRuleType) Number() protoreflect.EnumNumber { @@ -331,7 +379,7 @@ func (x LiquidityRuleType) Number() protoreflect.EnumNumber { // Deprecated: Use LiquidityRuleType.Descriptor instead. func (LiquidityRuleType) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{4} + return file_client_proto_rawDescGZIP(), []int{5} } type AutoReason int32 @@ -376,6 +424,12 @@ const ( // Fee insufficient indicates that the fee estimate for a swap is higher than // the portion of total swap amount that we allow fees to consume. AutoReason_AUTO_REASON_FEE_INSUFFICIENT AutoReason = 13 + // No static loop-in candidate indicates that static loop-in autoloop was + // selected, but no full-deposit static candidate fit the rule. + AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE AutoReason = 14 + // Custom channel data indicates that the target channel carries custom + // channel data and is excluded from the standard autoloop planner. + AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA AutoReason = 15 ) // Enum value maps for AutoReason. @@ -395,22 +449,26 @@ var ( 11: "AUTO_REASON_LIQUIDITY_OK", 12: "AUTO_REASON_BUDGET_INSUFFICIENT", 13: "AUTO_REASON_FEE_INSUFFICIENT", + 14: "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE", + 15: "AUTO_REASON_CUSTOM_CHANNEL_DATA", } AutoReason_value = map[string]int32{ - "AUTO_REASON_UNKNOWN": 0, - "AUTO_REASON_BUDGET_NOT_STARTED": 1, - "AUTO_REASON_SWEEP_FEES": 2, - "AUTO_REASON_BUDGET_ELAPSED": 3, - "AUTO_REASON_IN_FLIGHT": 4, - "AUTO_REASON_SWAP_FEE": 5, - "AUTO_REASON_MINER_FEE": 6, - "AUTO_REASON_PREPAY": 7, - "AUTO_REASON_FAILURE_BACKOFF": 8, - "AUTO_REASON_LOOP_OUT": 9, - "AUTO_REASON_LOOP_IN": 10, - "AUTO_REASON_LIQUIDITY_OK": 11, - "AUTO_REASON_BUDGET_INSUFFICIENT": 12, - "AUTO_REASON_FEE_INSUFFICIENT": 13, + "AUTO_REASON_UNKNOWN": 0, + "AUTO_REASON_BUDGET_NOT_STARTED": 1, + "AUTO_REASON_SWEEP_FEES": 2, + "AUTO_REASON_BUDGET_ELAPSED": 3, + "AUTO_REASON_IN_FLIGHT": 4, + "AUTO_REASON_SWAP_FEE": 5, + "AUTO_REASON_MINER_FEE": 6, + "AUTO_REASON_PREPAY": 7, + "AUTO_REASON_FAILURE_BACKOFF": 8, + "AUTO_REASON_LOOP_OUT": 9, + "AUTO_REASON_LOOP_IN": 10, + "AUTO_REASON_LIQUIDITY_OK": 11, + "AUTO_REASON_BUDGET_INSUFFICIENT": 12, + "AUTO_REASON_FEE_INSUFFICIENT": 13, + "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE": 14, + "AUTO_REASON_CUSTOM_CHANNEL_DATA": 15, } ) @@ -425,11 +483,11 @@ func (x AutoReason) String() string { } func (AutoReason) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[5].Descriptor() + return file_client_proto_enumTypes[6].Descriptor() } func (AutoReason) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[5] + return &file_client_proto_enumTypes[6] } func (x AutoReason) Number() protoreflect.EnumNumber { @@ -438,7 +496,7 @@ func (x AutoReason) Number() protoreflect.EnumNumber { // Deprecated: Use AutoReason.Descriptor instead. func (AutoReason) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{5} + return file_client_proto_rawDescGZIP(), []int{6} } type DepositState int32 @@ -530,11 +588,11 @@ func (x DepositState) String() string { } func (DepositState) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[6].Descriptor() + return file_client_proto_enumTypes[7].Descriptor() } func (DepositState) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[6] + return &file_client_proto_enumTypes[7] } func (x DepositState) Number() protoreflect.EnumNumber { @@ -543,7 +601,7 @@ func (x DepositState) Number() protoreflect.EnumNumber { // Deprecated: Use DepositState.Descriptor instead. func (DepositState) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{6} + return file_client_proto_rawDescGZIP(), []int{7} } type StaticAddressLoopInSwapState int32 @@ -606,11 +664,11 @@ func (x StaticAddressLoopInSwapState) String() string { } func (StaticAddressLoopInSwapState) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[7].Descriptor() + return file_client_proto_enumTypes[8].Descriptor() } func (StaticAddressLoopInSwapState) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[7] + return &file_client_proto_enumTypes[8] } func (x StaticAddressLoopInSwapState) Number() protoreflect.EnumNumber { @@ -619,7 +677,7 @@ func (x StaticAddressLoopInSwapState) Number() protoreflect.EnumNumber { // Deprecated: Use StaticAddressLoopInSwapState.Descriptor instead. func (StaticAddressLoopInSwapState) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{7} + return file_client_proto_rawDescGZIP(), []int{8} } type ListSwapsFilter_SwapTypeFilter int32 @@ -658,11 +716,11 @@ func (x ListSwapsFilter_SwapTypeFilter) String() string { } func (ListSwapsFilter_SwapTypeFilter) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[8].Descriptor() + return file_client_proto_enumTypes[9].Descriptor() } func (ListSwapsFilter_SwapTypeFilter) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[8] + return &file_client_proto_enumTypes[9] } func (x ListSwapsFilter_SwapTypeFilter) Number() protoreflect.EnumNumber { @@ -3455,8 +3513,10 @@ type LiquidityParameters struct { // autoloop run. If set, channels connected to these peers won't be // considered for easy autoloop swaps. EasyAutoloopExcludedPeers [][]byte `protobuf:"bytes,27,rep,name=easy_autoloop_excluded_peers,json=easyAutoloopExcludedPeers,proto3" json:"easy_autoloop_excluded_peers,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Selects which source autoloop uses for loop-in rules. + LoopInSource LoopInSource `protobuf:"varint,28,opt,name=loop_in_source,json=loopInSource,proto3,enum=looprpc.LoopInSource" json:"loop_in_source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LiquidityParameters) Reset() { @@ -3679,6 +3739,13 @@ func (x *LiquidityParameters) GetEasyAutoloopExcludedPeers() [][]byte { return nil } +func (x *LiquidityParameters) GetLoopInSource() LoopInSource { + if x != nil { + return x.LoopInSource + } + return LoopInSource_LOOP_IN_SOURCE_WALLET +} + type EasyAssetAutoloopParams struct { state protoimpl.MessageState `protogen:"open.v1"` // Set to true to enable easy autoloop for this asset. If set the client will @@ -4024,6 +4091,8 @@ type SuggestSwapsResponse struct { LoopOut []*LoopOutRequest `protobuf:"bytes,1,rep,name=loop_out,json=loopOut,proto3" json:"loop_out,omitempty"` // The set of recommended loop in swaps LoopIn []*LoopInRequest `protobuf:"bytes,3,rep,name=loop_in,json=loopIn,proto3" json:"loop_in,omitempty"` + // The set of recommended static-address loop in swaps. + StaticLoopIn []*StaticAddressLoopInRequest `protobuf:"bytes,4,rep,name=static_loop_in,json=staticLoopIn,proto3" json:"static_loop_in,omitempty"` // Disqualified contains the set of channels that swaps are not recommended // for. Disqualified []*Disqualified `protobuf:"bytes,2,rep,name=disqualified,proto3" json:"disqualified,omitempty"` @@ -4075,6 +4144,13 @@ func (x *SuggestSwapsResponse) GetLoopIn() []*LoopInRequest { return nil } +func (x *SuggestSwapsResponse) GetStaticLoopIn() []*StaticAddressLoopInRequest { + if x != nil { + return x.StaticLoopIn + } + return nil +} + func (x *SuggestSwapsResponse) GetDisqualified() []*Disqualified { if x != nil { return x.Disqualified @@ -6732,7 +6808,7 @@ const file_client_proto_rawDesc = "" + "\rloop_in_stats\x18\b \x01(\v2\x12.looprpc.LoopStatsR\vloopInStats\x12\x1f\n" + "\vcommit_hash\x18\t \x01(\tR\n" + "commitHash\"\x1b\n" + - "\x19GetLiquidityParamsRequest\"\xce\v\n" + + "\x19GetLiquidityParamsRequest\"\x8b\f\n" + "\x13LiquidityParameters\x12,\n" + "\x05rules\x18\x01 \x03(\v2\x16.looprpc.LiquidityRuleR\x05rules\x12\x17\n" + "\afee_ppm\x18\x10 \x01(\x04R\x06feePpm\x12=\n" + @@ -6761,7 +6837,8 @@ const file_client_proto_rawDesc = "" + "\x11account_addr_type\x18\x18 \x01(\x0e2\x14.looprpc.AddressTypeR\x0faccountAddrType\x12]\n" + "\x11easy_asset_params\x18\x19 \x03(\v21.looprpc.LiquidityParameters.EasyAssetParamsEntryR\x0feasyAssetParams\x122\n" + "\x15fast_swap_publication\x18\x1a \x01(\bR\x13fastSwapPublication\x12?\n" + - "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x1ad\n" + + "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x12;\n" + + "\x0eloop_in_source\x18\x1c \x01(\x0e2\x15.looprpc.LoopInSourceR\floopInSource\x1ad\n" + "\x14EasyAssetParamsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x126\n" + "\x05value\x18\x02 \x01(\v2 .looprpc.EasyAssetAutoloopParamsR\x05value:\x028\x01\"h\n" + @@ -6786,10 +6863,11 @@ const file_client_proto_rawDesc = "" + "\n" + "channel_id\x18\x01 \x01(\x04R\tchannelId\x12\x16\n" + "\x06pubkey\x18\x03 \x01(\fR\x06pubkey\x12+\n" + - "\x06reason\x18\x02 \x01(\x0e2\x13.looprpc.AutoReasonR\x06reason\"\xb6\x01\n" + + "\x06reason\x18\x02 \x01(\x0e2\x13.looprpc.AutoReasonR\x06reason\"\x81\x02\n" + "\x14SuggestSwapsResponse\x122\n" + "\bloop_out\x18\x01 \x03(\v2\x17.looprpc.LoopOutRequestR\aloopOut\x12/\n" + - "\aloop_in\x18\x03 \x03(\v2\x16.looprpc.LoopInRequestR\x06loopIn\x129\n" + + "\aloop_in\x18\x03 \x03(\v2\x16.looprpc.LoopInRequestR\x06loopIn\x12I\n" + + "\x0estatic_loop_in\x18\x04 \x03(\v2#.looprpc.StaticAddressLoopInRequestR\fstaticLoopIn\x129\n" + "\fdisqualified\x18\x02 \x03(\v2\x15.looprpc.DisqualifiedR\fdisqualified\"W\n" + "\x12AbandonSwapRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x121\n" + @@ -6980,10 +7058,13 @@ const file_client_proto_rawDesc = "" + "\x1fFAILURE_REASON_INCORRECT_AMOUNT\x10\x06\x12\x1c\n" + "\x18FAILURE_REASON_ABANDONED\x10\a\x121\n" + "-FAILURE_REASON_INSUFFICIENT_CONFIRMED_BALANCE\x10\b\x12+\n" + - "'FAILURE_REASON_INCORRECT_HTLC_AMT_SWEPT\x10\t*/\n" + + "'FAILURE_REASON_INCORRECT_HTLC_AMT_SWEPT\x10\t*L\n" + + "\fLoopInSource\x12\x19\n" + + "\x15LOOP_IN_SOURCE_WALLET\x10\x00\x12!\n" + + "\x1dLOOP_IN_SOURCE_STATIC_ADDRESS\x10\x01*/\n" + "\x11LiquidityRuleType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\r\n" + - "\tTHRESHOLD\x10\x01*\xa6\x03\n" + + "\tTHRESHOLD\x10\x01*\xf8\x03\n" + "\n" + "AutoReason\x12\x17\n" + "\x13AUTO_REASON_UNKNOWN\x10\x00\x12\"\n" + @@ -7000,7 +7081,9 @@ const file_client_proto_rawDesc = "" + "\x12\x1c\n" + "\x18AUTO_REASON_LIQUIDITY_OK\x10\v\x12#\n" + "\x1fAUTO_REASON_BUDGET_INSUFFICIENT\x10\f\x12 \n" + - "\x1cAUTO_REASON_FEE_INSUFFICIENT\x10\r*\x88\x02\n" + + "\x1cAUTO_REASON_FEE_INSUFFICIENT\x10\r\x12+\n" + + "'AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE\x10\x0e\x12#\n" + + "\x1fAUTO_REASON_CUSTOM_CHANNEL_DATA\x10\x0f*\x88\x02\n" + "\fDepositState\x12\x11\n" + "\rUNKNOWN_STATE\x10\x00\x12\r\n" + "\tDEPOSITED\x10\x01\x12\x0f\n" + @@ -7081,223 +7164,226 @@ func file_client_proto_rawDescGZIP() []byte { return file_client_proto_rawDescData } -var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 9) +var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 80) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType (SwapState)(0), // 2: looprpc.SwapState (FailureReason)(0), // 3: looprpc.FailureReason - (LiquidityRuleType)(0), // 4: looprpc.LiquidityRuleType - (AutoReason)(0), // 5: looprpc.AutoReason - (DepositState)(0), // 6: looprpc.DepositState - (StaticAddressLoopInSwapState)(0), // 7: looprpc.StaticAddressLoopInSwapState - (ListSwapsFilter_SwapTypeFilter)(0), // 8: looprpc.ListSwapsFilter.SwapTypeFilter - (*StaticOpenChannelRequest)(nil), // 9: looprpc.StaticOpenChannelRequest - (*StaticOpenChannelResponse)(nil), // 10: looprpc.StaticOpenChannelResponse - (*StopDaemonRequest)(nil), // 11: looprpc.StopDaemonRequest - (*StopDaemonResponse)(nil), // 12: looprpc.StopDaemonResponse - (*LoopOutRequest)(nil), // 13: looprpc.LoopOutRequest - (*LoopInRequest)(nil), // 14: looprpc.LoopInRequest - (*SwapResponse)(nil), // 15: looprpc.SwapResponse - (*MonitorRequest)(nil), // 16: looprpc.MonitorRequest - (*SwapStatus)(nil), // 17: looprpc.SwapStatus - (*ListSwapsRequest)(nil), // 18: looprpc.ListSwapsRequest - (*ListSwapsFilter)(nil), // 19: looprpc.ListSwapsFilter - (*ListSwapsResponse)(nil), // 20: looprpc.ListSwapsResponse - (*SweepHtlcRequest)(nil), // 21: looprpc.SweepHtlcRequest - (*SweepHtlcResponse)(nil), // 22: looprpc.SweepHtlcResponse - (*PublishNotRequested)(nil), // 23: looprpc.PublishNotRequested - (*PublishSucceeded)(nil), // 24: looprpc.PublishSucceeded - (*PublishFailed)(nil), // 25: looprpc.PublishFailed - (*SwapInfoRequest)(nil), // 26: looprpc.SwapInfoRequest - (*TermsRequest)(nil), // 27: looprpc.TermsRequest - (*InTermsResponse)(nil), // 28: looprpc.InTermsResponse - (*OutTermsResponse)(nil), // 29: looprpc.OutTermsResponse - (*QuoteRequest)(nil), // 30: looprpc.QuoteRequest - (*InQuoteResponse)(nil), // 31: looprpc.InQuoteResponse - (*OutQuoteResponse)(nil), // 32: looprpc.OutQuoteResponse - (*ProbeRequest)(nil), // 33: looprpc.ProbeRequest - (*ProbeResponse)(nil), // 34: looprpc.ProbeResponse - (*TokensRequest)(nil), // 35: looprpc.TokensRequest - (*TokensResponse)(nil), // 36: looprpc.TokensResponse - (*FetchL402TokenRequest)(nil), // 37: looprpc.FetchL402TokenRequest - (*FetchL402TokenResponse)(nil), // 38: looprpc.FetchL402TokenResponse - (*L402Token)(nil), // 39: looprpc.L402Token - (*LoopStats)(nil), // 40: looprpc.LoopStats - (*GetInfoRequest)(nil), // 41: looprpc.GetInfoRequest - (*GetInfoResponse)(nil), // 42: looprpc.GetInfoResponse - (*GetLiquidityParamsRequest)(nil), // 43: looprpc.GetLiquidityParamsRequest - (*LiquidityParameters)(nil), // 44: looprpc.LiquidityParameters - (*EasyAssetAutoloopParams)(nil), // 45: looprpc.EasyAssetAutoloopParams - (*LiquidityRule)(nil), // 46: looprpc.LiquidityRule - (*SetLiquidityParamsRequest)(nil), // 47: looprpc.SetLiquidityParamsRequest - (*SetLiquidityParamsResponse)(nil), // 48: looprpc.SetLiquidityParamsResponse - (*SuggestSwapsRequest)(nil), // 49: looprpc.SuggestSwapsRequest - (*Disqualified)(nil), // 50: looprpc.Disqualified - (*SuggestSwapsResponse)(nil), // 51: looprpc.SuggestSwapsResponse - (*AbandonSwapRequest)(nil), // 52: looprpc.AbandonSwapRequest - (*AbandonSwapResponse)(nil), // 53: looprpc.AbandonSwapResponse - (*ListReservationsRequest)(nil), // 54: looprpc.ListReservationsRequest - (*ListReservationsResponse)(nil), // 55: looprpc.ListReservationsResponse - (*ClientReservation)(nil), // 56: looprpc.ClientReservation - (*InstantOutRequest)(nil), // 57: looprpc.InstantOutRequest - (*InstantOutResponse)(nil), // 58: looprpc.InstantOutResponse - (*InstantOutQuoteRequest)(nil), // 59: looprpc.InstantOutQuoteRequest - (*InstantOutQuoteResponse)(nil), // 60: looprpc.InstantOutQuoteResponse - (*ListInstantOutsRequest)(nil), // 61: looprpc.ListInstantOutsRequest - (*ListInstantOutsResponse)(nil), // 62: looprpc.ListInstantOutsResponse - (*InstantOut)(nil), // 63: looprpc.InstantOut - (*NewStaticAddressRequest)(nil), // 64: looprpc.NewStaticAddressRequest - (*NewStaticAddressResponse)(nil), // 65: looprpc.NewStaticAddressResponse - (*ListUnspentDepositsRequest)(nil), // 66: looprpc.ListUnspentDepositsRequest - (*ListUnspentDepositsResponse)(nil), // 67: looprpc.ListUnspentDepositsResponse - (*Utxo)(nil), // 68: looprpc.Utxo - (*WithdrawDepositsRequest)(nil), // 69: looprpc.WithdrawDepositsRequest - (*WithdrawDepositsResponse)(nil), // 70: looprpc.WithdrawDepositsResponse - (*ListStaticAddressDepositsRequest)(nil), // 71: looprpc.ListStaticAddressDepositsRequest - (*ListStaticAddressDepositsResponse)(nil), // 72: looprpc.ListStaticAddressDepositsResponse - (*ListStaticAddressWithdrawalRequest)(nil), // 73: looprpc.ListStaticAddressWithdrawalRequest - (*ListStaticAddressWithdrawalResponse)(nil), // 74: looprpc.ListStaticAddressWithdrawalResponse - (*ListStaticAddressSwapsRequest)(nil), // 75: looprpc.ListStaticAddressSwapsRequest - (*ListStaticAddressSwapsResponse)(nil), // 76: looprpc.ListStaticAddressSwapsResponse - (*StaticAddressSummaryRequest)(nil), // 77: looprpc.StaticAddressSummaryRequest - (*StaticAddressSummaryResponse)(nil), // 78: looprpc.StaticAddressSummaryResponse - (*Deposit)(nil), // 79: looprpc.Deposit - (*StaticAddressWithdrawal)(nil), // 80: looprpc.StaticAddressWithdrawal - (*StaticAddressLoopInSwap)(nil), // 81: looprpc.StaticAddressLoopInSwap - (*StaticAddressLoopInRequest)(nil), // 82: looprpc.StaticAddressLoopInRequest - (*StaticAddressLoopInResponse)(nil), // 83: looprpc.StaticAddressLoopInResponse - (*AssetLoopOutRequest)(nil), // 84: looprpc.AssetLoopOutRequest - (*AssetRfqInfo)(nil), // 85: looprpc.AssetRfqInfo - (*FixedPoint)(nil), // 86: looprpc.FixedPoint - (*AssetLoopOutInfo)(nil), // 87: looprpc.AssetLoopOutInfo - nil, // 88: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 89: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 90: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 91: lnrpc.OutPoint + (LoopInSource)(0), // 4: looprpc.LoopInSource + (LiquidityRuleType)(0), // 5: looprpc.LiquidityRuleType + (AutoReason)(0), // 6: looprpc.AutoReason + (DepositState)(0), // 7: looprpc.DepositState + (StaticAddressLoopInSwapState)(0), // 8: looprpc.StaticAddressLoopInSwapState + (ListSwapsFilter_SwapTypeFilter)(0), // 9: looprpc.ListSwapsFilter.SwapTypeFilter + (*StaticOpenChannelRequest)(nil), // 10: looprpc.StaticOpenChannelRequest + (*StaticOpenChannelResponse)(nil), // 11: looprpc.StaticOpenChannelResponse + (*StopDaemonRequest)(nil), // 12: looprpc.StopDaemonRequest + (*StopDaemonResponse)(nil), // 13: looprpc.StopDaemonResponse + (*LoopOutRequest)(nil), // 14: looprpc.LoopOutRequest + (*LoopInRequest)(nil), // 15: looprpc.LoopInRequest + (*SwapResponse)(nil), // 16: looprpc.SwapResponse + (*MonitorRequest)(nil), // 17: looprpc.MonitorRequest + (*SwapStatus)(nil), // 18: looprpc.SwapStatus + (*ListSwapsRequest)(nil), // 19: looprpc.ListSwapsRequest + (*ListSwapsFilter)(nil), // 20: looprpc.ListSwapsFilter + (*ListSwapsResponse)(nil), // 21: looprpc.ListSwapsResponse + (*SweepHtlcRequest)(nil), // 22: looprpc.SweepHtlcRequest + (*SweepHtlcResponse)(nil), // 23: looprpc.SweepHtlcResponse + (*PublishNotRequested)(nil), // 24: looprpc.PublishNotRequested + (*PublishSucceeded)(nil), // 25: looprpc.PublishSucceeded + (*PublishFailed)(nil), // 26: looprpc.PublishFailed + (*SwapInfoRequest)(nil), // 27: looprpc.SwapInfoRequest + (*TermsRequest)(nil), // 28: looprpc.TermsRequest + (*InTermsResponse)(nil), // 29: looprpc.InTermsResponse + (*OutTermsResponse)(nil), // 30: looprpc.OutTermsResponse + (*QuoteRequest)(nil), // 31: looprpc.QuoteRequest + (*InQuoteResponse)(nil), // 32: looprpc.InQuoteResponse + (*OutQuoteResponse)(nil), // 33: looprpc.OutQuoteResponse + (*ProbeRequest)(nil), // 34: looprpc.ProbeRequest + (*ProbeResponse)(nil), // 35: looprpc.ProbeResponse + (*TokensRequest)(nil), // 36: looprpc.TokensRequest + (*TokensResponse)(nil), // 37: looprpc.TokensResponse + (*FetchL402TokenRequest)(nil), // 38: looprpc.FetchL402TokenRequest + (*FetchL402TokenResponse)(nil), // 39: looprpc.FetchL402TokenResponse + (*L402Token)(nil), // 40: looprpc.L402Token + (*LoopStats)(nil), // 41: looprpc.LoopStats + (*GetInfoRequest)(nil), // 42: looprpc.GetInfoRequest + (*GetInfoResponse)(nil), // 43: looprpc.GetInfoResponse + (*GetLiquidityParamsRequest)(nil), // 44: looprpc.GetLiquidityParamsRequest + (*LiquidityParameters)(nil), // 45: looprpc.LiquidityParameters + (*EasyAssetAutoloopParams)(nil), // 46: looprpc.EasyAssetAutoloopParams + (*LiquidityRule)(nil), // 47: looprpc.LiquidityRule + (*SetLiquidityParamsRequest)(nil), // 48: looprpc.SetLiquidityParamsRequest + (*SetLiquidityParamsResponse)(nil), // 49: looprpc.SetLiquidityParamsResponse + (*SuggestSwapsRequest)(nil), // 50: looprpc.SuggestSwapsRequest + (*Disqualified)(nil), // 51: looprpc.Disqualified + (*SuggestSwapsResponse)(nil), // 52: looprpc.SuggestSwapsResponse + (*AbandonSwapRequest)(nil), // 53: looprpc.AbandonSwapRequest + (*AbandonSwapResponse)(nil), // 54: looprpc.AbandonSwapResponse + (*ListReservationsRequest)(nil), // 55: looprpc.ListReservationsRequest + (*ListReservationsResponse)(nil), // 56: looprpc.ListReservationsResponse + (*ClientReservation)(nil), // 57: looprpc.ClientReservation + (*InstantOutRequest)(nil), // 58: looprpc.InstantOutRequest + (*InstantOutResponse)(nil), // 59: looprpc.InstantOutResponse + (*InstantOutQuoteRequest)(nil), // 60: looprpc.InstantOutQuoteRequest + (*InstantOutQuoteResponse)(nil), // 61: looprpc.InstantOutQuoteResponse + (*ListInstantOutsRequest)(nil), // 62: looprpc.ListInstantOutsRequest + (*ListInstantOutsResponse)(nil), // 63: looprpc.ListInstantOutsResponse + (*InstantOut)(nil), // 64: looprpc.InstantOut + (*NewStaticAddressRequest)(nil), // 65: looprpc.NewStaticAddressRequest + (*NewStaticAddressResponse)(nil), // 66: looprpc.NewStaticAddressResponse + (*ListUnspentDepositsRequest)(nil), // 67: looprpc.ListUnspentDepositsRequest + (*ListUnspentDepositsResponse)(nil), // 68: looprpc.ListUnspentDepositsResponse + (*Utxo)(nil), // 69: looprpc.Utxo + (*WithdrawDepositsRequest)(nil), // 70: looprpc.WithdrawDepositsRequest + (*WithdrawDepositsResponse)(nil), // 71: looprpc.WithdrawDepositsResponse + (*ListStaticAddressDepositsRequest)(nil), // 72: looprpc.ListStaticAddressDepositsRequest + (*ListStaticAddressDepositsResponse)(nil), // 73: looprpc.ListStaticAddressDepositsResponse + (*ListStaticAddressWithdrawalRequest)(nil), // 74: looprpc.ListStaticAddressWithdrawalRequest + (*ListStaticAddressWithdrawalResponse)(nil), // 75: looprpc.ListStaticAddressWithdrawalResponse + (*ListStaticAddressSwapsRequest)(nil), // 76: looprpc.ListStaticAddressSwapsRequest + (*ListStaticAddressSwapsResponse)(nil), // 77: looprpc.ListStaticAddressSwapsResponse + (*StaticAddressSummaryRequest)(nil), // 78: looprpc.StaticAddressSummaryRequest + (*StaticAddressSummaryResponse)(nil), // 79: looprpc.StaticAddressSummaryResponse + (*Deposit)(nil), // 80: looprpc.Deposit + (*StaticAddressWithdrawal)(nil), // 81: looprpc.StaticAddressWithdrawal + (*StaticAddressLoopInSwap)(nil), // 82: looprpc.StaticAddressLoopInSwap + (*StaticAddressLoopInRequest)(nil), // 83: looprpc.StaticAddressLoopInRequest + (*StaticAddressLoopInResponse)(nil), // 84: looprpc.StaticAddressLoopInResponse + (*AssetLoopOutRequest)(nil), // 85: looprpc.AssetLoopOutRequest + (*AssetRfqInfo)(nil), // 86: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 87: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo + nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 89, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType - 84, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 91, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 3, // 7: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 87, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo - 19, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter - 8, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter - 17, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus - 23, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 24, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 25, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 90, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 84, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 39, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 40, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 40, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 46, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 88, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 20, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter + 9, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter + 18, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus + 24, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 91, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 91, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule 0, // 23: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 88, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 1, // 25: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 4, // 26: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 44, // 27: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 5, // 28: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 13, // 29: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 14, // 30: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 50, // 31: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 56, // 32: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 63, // 33: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 68, // 34: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 91, // 35: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 6, // 36: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 79, // 37: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 80, // 38: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 81, // 39: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 6, // 40: looprpc.Deposit.state:type_name -> looprpc.DepositState - 79, // 41: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 7, // 42: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 79, // 43: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 90, // 44: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 79, // 45: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 86, // 46: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 86, // 47: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 45, // 48: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 13, // 49: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 14, // 50: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 16, // 51: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 18, // 52: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 21, // 53: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 26, // 54: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 52, // 55: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 27, // 56: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 30, // 57: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 27, // 58: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 30, // 59: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 33, // 60: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 35, // 61: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 35, // 62: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 37, // 63: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 41, // 64: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 11, // 65: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 43, // 66: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 47, // 67: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 49, // 68: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 54, // 69: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 57, // 70: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 59, // 71: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 61, // 72: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 64, // 73: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 66, // 74: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 69, // 75: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 71, // 76: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 73, // 77: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 75, // 78: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 77, // 79: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 82, // 80: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 9, // 81: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 15, // 82: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 15, // 83: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 17, // 84: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 20, // 85: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 22, // 86: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 17, // 87: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 53, // 88: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 29, // 89: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 32, // 90: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 28, // 91: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 31, // 92: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 34, // 93: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 36, // 94: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 36, // 95: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 38, // 96: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 42, // 97: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 12, // 98: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 44, // 99: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 48, // 100: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 51, // 101: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 55, // 102: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 58, // 103: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 60, // 104: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 62, // 105: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 65, // 106: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 67, // 107: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 70, // 108: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 72, // 109: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 74, // 110: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 76, // 111: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 78, // 112: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 83, // 113: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 10, // 114: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 82, // [82:115] is the sub-list for method output_type - 49, // [49:82] is the sub-list for method input_type - 49, // [49:49] is the sub-list for extension type_name - 49, // [49:49] is the sub-list for extension extendee - 0, // [0:49] is the sub-list for field type_name + 89, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 25: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 26: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 27: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 28: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 29: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 30: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 31: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 83, // 32: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 33: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 34: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 35: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 92, // 37: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 38: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 41: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 42: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 43: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 44: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 50: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 51: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 52: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 53: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 54: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 55: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 56: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 57: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 58: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 59: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 60: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 61: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 62: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 63: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 64: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 65: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 66: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 67: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 68: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 69: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 70: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 71: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 72: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 73: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 74: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 75: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 76: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 77: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 78: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 79: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 80: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 81: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 82: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 83: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 84: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 85: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 86: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 87: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 88: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 89: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 90: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 91: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 92: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 93: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 94: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 95: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 96: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 97: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 98: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 99: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 100: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 101: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 102: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 103: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 104: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 105: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 106: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 107: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 108: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 109: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 110: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 111: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 112: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 113: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 114: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 115: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 116: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 84, // [84:117] is the sub-list for method output_type + 51, // [51:84] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7315,7 +7401,7 @@ func file_client_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), - NumEnums: 9, + NumEnums: 10, NumMessages: 80, NumExtensions: 0, NumServices: 1, diff --git a/looprpc/client.proto b/looprpc/client.proto index cf14ffa0a..52b9fcbb0 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1192,6 +1192,18 @@ message GetInfoResponse { message GetLiquidityParamsRequest { } +enum LoopInSource { + /* + Use the legacy wallet-funded loop-in flow. + */ + LOOP_IN_SOURCE_WALLET = 0; + + /* + Use deposited static-address funds for loop-in autoloops. + */ + LOOP_IN_SOURCE_STATIC_ADDRESS = 1; +} + message LiquidityParameters { /* A set of liquidity rules that describe the desired liquidity balance. @@ -1370,6 +1382,11 @@ message LiquidityParameters { considered for easy autoloop swaps. */ repeated bytes easy_autoloop_excluded_peers = 27; + + /* + Selects which source autoloop uses for loop-in rules. + */ + LoopInSource loop_in_source = 28; } message EasyAssetAutoloopParams { @@ -1526,6 +1543,18 @@ enum AutoReason { the portion of total swap amount that we allow fees to consume. */ AUTO_REASON_FEE_INSUFFICIENT = 13; + + /* + No static loop-in candidate indicates that static loop-in autoloop was + selected, but no full-deposit static candidate fit the rule. + */ + AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE = 14; + + /* + Custom channel data indicates that the target channel carries custom + channel data and is excluded from the standard autoloop planner. + */ + AUTO_REASON_CUSTOM_CHANNEL_DATA = 15; } message Disqualified { @@ -1556,6 +1585,11 @@ message SuggestSwapsResponse { */ repeated LoopInRequest loop_in = 3; + /* + The set of recommended static-address loop in swaps. + */ + repeated StaticAddressLoopInRequest static_loop_in = 4; + /* Disqualified contains the set of channels that swaps are not recommended for. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 3d75d15da..e336cb7dc 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1543,10 +1543,12 @@ "AUTO_REASON_LOOP_IN", "AUTO_REASON_LIQUIDITY_OK", "AUTO_REASON_BUDGET_INSUFFICIENT", - "AUTO_REASON_FEE_INSUFFICIENT" + "AUTO_REASON_FEE_INSUFFICIENT", + "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE", + "AUTO_REASON_CUSTOM_CHANNEL_DATA" ], "default": "AUTO_REASON_UNKNOWN", - "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume." + "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume.\n - AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE: No static loop-in candidate indicates that static loop-in autoloop was\nselected, but no full-deposit static candidate fit the rule.\n - AUTO_REASON_CUSTOM_CHANNEL_DATA: Custom channel data indicates that the target channel carries custom\nchannel data and is excluded from the standard autoloop planner." }, "looprpcClientReservation": { "type": "object", @@ -2120,6 +2122,10 @@ "format": "byte" }, "description": "A list of peers (their public keys) that should be excluded from the easy\nautoloop run. If set, channels connected to these peers won't be\nconsidered for easy autoloop swaps." + }, + "loop_in_source": { + "$ref": "#/definitions/looprpcLoopInSource", + "description": "Selects which source autoloop uses for loop-in rules." } } }, @@ -2353,6 +2359,15 @@ } } }, + "looprpcLoopInSource": { + "type": "string", + "enum": [ + "LOOP_IN_SOURCE_WALLET", + "LOOP_IN_SOURCE_STATIC_ADDRESS" + ], + "default": "LOOP_IN_SOURCE_WALLET", + "description": " - LOOP_IN_SOURCE_WALLET: Use the legacy wallet-funded loop-in flow.\n - LOOP_IN_SOURCE_STATIC_ADDRESS: Use deposited static-address funds for loop-in autoloops." + }, "looprpcLoopOutRequest": { "type": "object", "properties": { @@ -2935,6 +2950,14 @@ }, "title": "The set of recommended loop in swaps" }, + "static_loop_in": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/looprpcStaticAddressLoopInRequest" + }, + "description": "The set of recommended static-address loop in swaps." + }, "disqualified": { "type": "array", "items": { diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go new file mode 100644 index 000000000..851941e82 --- /dev/null +++ b/staticaddr/loopin/autoloop.go @@ -0,0 +1,254 @@ +package loopin + +import ( + "context" + "errors" + "slices" + "sort" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/routing/route" +) + +var ( + // ErrNoAutoloopCandidate is returned when the static-address side + // cannot build a full-deposit, no-change loop-in candidate that fits + // the planner's requested amount bounds. + ErrNoAutoloopCandidate = errors.New("no autoloop candidate") +) + +// PrepareAutoloopLoopIn builds a static-address loop-in request for autoloop +// without dispatching it. The returned request always uses full deposits, +// explicit outpoints, and an explicit selected amount, so the caller can +// account for the suggestion without depending on static-address internals. +func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context, + lastHop route.Vertex, minAmount, maxAmount btcutil.Amount, label, + initiator string, excludedOutpoints []string) ( + *loop.StaticAddressLoopInRequest, int, bool, error) { + + if minAmount <= 0 || maxAmount < minAmount { + return nil, 0, false, ErrNoAutoloopCandidate + } + + allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( + deposit.Deposited, + ) + if err != nil { + return nil, 0, false, err + } + + params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + if err != nil { + return nil, 0, false, err + } + + excluded := make(map[string]struct{}, len(excludedOutpoints)) + for _, outpoint := range excludedOutpoints { + excluded[outpoint] = struct{}{} + } + + selectedDeposits, err := selectNoChangeDeposits( + maxAmount, minAmount, allDeposits, params.Expiry, + m.currentHeight.Load(), excluded, + ) + if err != nil { + return nil, 0, false, err + } + + selectedAmount := sumOfDeposits(selectedDeposits) + quote, err := m.cfg.QuoteGetter.GetLoopInQuote( + ctx, selectedAmount, m.cfg.NodePubkey, &lastHop, nil, + initiator, uint32(len(selectedDeposits)), false, + ) + if err != nil { + return nil, 0, false, err + } + + outpoints := make([]string, 0, len(selectedDeposits)) + for _, selectedDeposit := range selectedDeposits { + outpoints = append(outpoints, selectedDeposit.OutPoint.String()) + } + + request := &loop.StaticAddressLoopInRequest{ + DepositOutpoints: outpoints, + SelectedAmount: selectedAmount, + MaxSwapFee: quote.SwapFee, + LastHop: &lastHop, + Label: label, + Initiator: initiator, + Fast: false, + } + + return request, len(selectedDeposits), false, nil +} + +// selectNoChangeDeposits chooses the highest-value swappable deposit set whose +// full value stays within the requested range. The selector never creates +// change, so the returned set's total is the actual swap amount. +func selectNoChangeDeposits(maxAmount, minAmount btcutil.Amount, + unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32, + excludedOutpoints map[string]struct{}) ([]*deposit.Deposit, error) { + + // Filter out deposits that cannot safely participate in a loop-in or + // were already allocated to a larger suggestion earlier in the same + // planning pass. + deposits := make([]*deposit.Deposit, 0, len(unfilteredDeposits)) + for _, deposit := range unfilteredDeposits { + if _, ok := excludedOutpoints[deposit.OutPoint.String()]; ok { + continue + } + + swappable := IsSwappable( + uint32(deposit.ConfirmationHeight), blockHeight, + csvExpiry, + ) + if !swappable { + continue + } + + if deposit.Value > maxAmount { + continue + } + + deposits = append(deposits, deposit) + } + + if len(deposits) == 0 { + return nil, ErrNoAutoloopCandidate + } + + // Sort by value so the search finds large feasible totals early. The + // expiry tie-break keeps equal-value deposits deterministic and helps + // the later candidate comparison prefer sooner-expiring funds. + sort.SliceStable(deposits, func(i, j int) bool { + if deposits[i].Value == deposits[j].Value { + return deposits[i].ConfirmationHeight < + deposits[j].ConfirmationHeight + } + + return deposits[i].Value > deposits[j].Value + }) + + // Precompute a suffix sum so branches that cannot possibly beat the + // current best total can be pruned before exploring the expensive part + // of the search tree. + suffixSums := make([]btcutil.Amount, len(deposits)+1) + for i := len(deposits) - 1; i >= 0; i-- { + suffixSums[i] = suffixSums[i+1] + deposits[i].Value + } + + var ( + bestSelection []int + bestTotal btcutil.Amount + ) + + // betterSelection applies the full-deposit ordering: + // 1. highest total not exceeding the target + // 2. fewer deposits + // 3. earlier-expiring deposits + betterSelection := func(candidate []int, total btcutil.Amount) bool { + switch { + case total > bestTotal: + return true + + case total < bestTotal: + return false + + case bestSelection == nil: + return true + + case len(candidate) < len(bestSelection): + return true + + case len(candidate) > len(bestSelection): + return false + } + + // Use signed arithmetic here so an expired deposit cannot wrap + // the residual-life comparison if height updates race the + // earlier swappability filter. + left := make([]int64, len(candidate)) + for i, index := range candidate { + left[i] = deposits[index].ConfirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + right := make([]int64, len(bestSelection)) + for i, index := range bestSelection { + right[i] = deposits[index].ConfirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + slices.Sort(left) + slices.Sort(right) + + for i := range left { + if left[i] == right[i] { + continue + } + + return left[i] < right[i] + } + + return false + } + + // search explores include/exclude choices. The branch-and-bound checks + // are intentionally conservative: they only prune when no combination + // below the current node can beat the best known total or tie it with a + // smaller deposit count. + var search func(index int, total btcutil.Amount, selected []int) + search = func(index int, total btcutil.Amount, selected []int) { + if total > maxAmount { + return + } + + if total >= minAmount && betterSelection(selected, total) { + bestTotal = total + bestSelection = append([]int(nil), selected...) + } + + if index == len(deposits) { + return + } + + maxReachable := total + suffixSums[index] + if maxReachable < bestTotal { + return + } + + if maxReachable == bestTotal && bestSelection != nil && + len(selected) >= len(bestSelection) { + + return + } + + // The include branch must not reuse selected's backing array. + // Otherwise a later append can leak into the exclude branch + // when the slice still has spare capacity. + selectedWithIndex := make([]int, len(selected)+1) + copy(selectedWithIndex, selected) + selectedWithIndex[len(selected)] = index + + search( + index+1, total+deposits[index].Value, + selectedWithIndex, + ) + search(index+1, total, selected) + } + + search(0, 0, nil) + + if len(bestSelection) == 0 { + return nil, ErrNoAutoloopCandidate + } + + selectedDeposits := make([]*deposit.Deposit, 0, len(bestSelection)) + for _, index := range bestSelection { + selectedDeposits = append(selectedDeposits, deposits[index]) + } + + return selectedDeposits, nil +} diff --git a/staticaddr/loopin/autoloop_test.go b/staticaddr/loopin/autoloop_test.go new file mode 100644 index 000000000..2e32eea5f --- /dev/null +++ b/staticaddr/loopin/autoloop_test.go @@ -0,0 +1,330 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestSelectNoChangeDeposits verifies the full-deposit static-autoloop +// selector. The cases below target the filter paths, the branch-and-bound +// search, and every documented tie-breaker explicitly so coverage tracks the +// actual selection behavior instead of a handful of happy-path examples. +func TestSelectNoChangeDeposits(t *testing.T) { + depositSeven := makeDeposit(7, 0, 7_000, 200) + depositFour := makeDeposit(4, 0, 4_000, 210) + depositThreeA := makeDeposit(3, 0, 3_000, 220) + depositThreeB := makeDeposit(9, 0, 3_000, 221) + depositNine := makeDeposit(8, 0, 9_000, 205) + depositFourA := makeDeposit(5, 0, 4_000, 215) + depositFourB := makeDeposit(6, 0, 4_000, 216) + depositOneA := makeDeposit(10, 0, 1_000, 230) + depositOneB := makeDeposit(11, 0, 1_000, 231) + depositOneC := makeDeposit(21, 0, 1_000, 232) + depositFourC := makeDeposit(13, 0, 4_000, 200) + depositFourD := makeDeposit(14, 0, 4_000, 201) + depositFourE := makeDeposit(15, 0, 4_000, 220) + depositFourF := makeDeposit(16, 0, 4_000, 221) + depositFive := makeDeposit(17, 0, 5_000, 200) + depositUnsuitable := makeDeposit(18, 0, 6_000, 149) + depositOversized := makeDeposit(19, 0, 9_000, 220) + depositTwo := makeDeposit(20, 0, 2_000, 210) + + testCases := []struct { + name string + maxAmount btcutil.Amount + minAmount btcutil.Amount + deposits []*deposit.Deposit + csvExpiry uint32 + blockHeight uint32 + excludedOutpoint map[string]struct{} + expected []*deposit.Deposit + expectedErr error + }{ + { + name: "prefers exact deposit over smaller combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositSeven}, + }, + { + name: "excluded outpoint falls back to combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + excludedOutpoint: map[string]struct{}{ + depositSeven.OutPoint.String(): {}, + }, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "same total prefers fewer deposits", + maxAmount: 6_000, + minAmount: 6_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, depositThreeB, + depositOneA, depositOneB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositThreeA, depositThreeB, + }, + }, + { + name: "same total rejects more deposits", + maxAmount: 2_000, + minAmount: 2_000, + deposits: []*deposit.Deposit{ + depositTwo, depositOneA, + depositOneB, depositOneC, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositTwo}, + }, + { + name: "same total prefers earlier expiries", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFourC, depositFourD, + depositFourE, depositFourF, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFourC, depositFourD, + }, + }, + { + name: "identical residual lives keep stable pick", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + depositFourA, depositThreeB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositFourA, + }, + }, + { + name: "filters unswappable and oversized deposits", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositFive, depositUnsuitable, + depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositFive}, + }, + { + name: "returns no candidate when all are filtered", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositUnsuitable, depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "returns no candidate below minimum", + maxAmount: 10_000, + minAmount: 7_000, + deposits: []*deposit.Deposit{ + depositFour, depositTwo, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "zero minimum finds best positive total", + maxAmount: 7_000, + minAmount: 0, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "deeper search isolates include and exclude", + maxAmount: 13_000, + minAmount: 10_000, + deposits: []*deposit.Deposit{ + depositNine, depositSeven, + depositFourA, depositFourB, + depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositNine, depositFourA, + }, + }, + } + + selectedOutpoints := func(deposits []*deposit.Deposit) []string { + result := make([]string, 0, len(deposits)) + for _, selectedDeposit := range deposits { + result = append( + result, selectedDeposit.OutPoint.String(), + ) + } + + return result + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + selectedDeposits, err := selectNoChangeDeposits( + testCase.maxAmount, testCase.minAmount, + testCase.deposits, testCase.csvExpiry, + testCase.blockHeight, testCase.excludedOutpoint, + ) + + if testCase.expectedErr != nil { + require.ErrorIs(t, err, testCase.expectedErr) + require.Nil(t, selectedDeposits) + } else { + require.NoError(t, err) + require.Equal( + t, selectedOutpoints(testCase.expected), + selectedOutpoints(selectedDeposits), + ) + } + }) + } +} + +// TestPrepareAutoloopLoopIn ensures the static manager returns an explicit +// full-deposit request and quotes it with the correct amount and deposit +// count. +func TestPrepareAutoloopLoopIn(t *testing.T) { + ctx := t.Context() + + selectedDeposit := makeDeposit(1, 0, 9_000, 300) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 123, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{selectedDeposit}, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + lastHop := route.Vertex{9} + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, lastHop, 5_000, 10_000, "label", "autoloop", nil, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(123), request.MaxSwapFee) + require.NotNil(t, request.LastHop) + require.Equal(t, lastHop, *request.LastHop) + require.Equal(t, "label", request.Label) + require.Equal(t, "autoloop", request.Initiator) + require.False(t, request.Fast) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) + require.NotNil(t, quoteGetter.lastHop) + require.Equal(t, lastHop, *quoteGetter.lastHop) + require.Equal(t, "autoloop", quoteGetter.initiator) + require.Equal(t, uint32(1), quoteGetter.numDeposits) + require.False(t, quoteGetter.fast) +} + +// TestPrepareAutoloopLoopInExcludedOutpoints verifies that the manager passes +// excluded outpoints through the end-to-end preparation path before quoting +// the candidate. +func TestPrepareAutoloopLoopInExcludedOutpoints(t *testing.T) { + ctx := t.Context() + + excludedDeposit := makeDeposit(1, 0, 9_000, 300) + + selectedDeposit := makeDeposit(2, 0, 7_000, 301) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 77, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + excludedDeposit, selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, route.Vertex{9}, 5_000, 10_000, "label", "autoloop", + []string{excludedDeposit.OutPoint.String()}, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(77), request.MaxSwapFee) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 37616c675..a1a702880 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -28,6 +28,7 @@ import ( "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" ) @@ -106,6 +107,9 @@ type StaticAddressLoopIn struct { // on the server side for this static loop in. Fast bool + // LastUpdateTime is the timestamp of the latest persisted state update. + LastUpdateTime time.Time + // state is the current state of the swap. state fsm.StateType @@ -487,6 +491,21 @@ func (l *StaticAddressLoopIn) Outpoints() []wire.OutPoint { return outpoints } +// LastHopVertex returns the swap's last hop as a route vertex when the field +// is present and well formed. +func (l *StaticAddressLoopIn) LastHopVertex() *route.Vertex { + if len(l.LastHop) == 0 { + return nil + } + + vertex, err := route.NewVertexFromBytes(l.LastHop) + if err != nil { + return nil + } + + return &vertex +} + // GetState returns the current state of the loop-in swap. func (l *StaticAddressLoopIn) GetState() fsm.StateType { l.mu.Lock() diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 444ab5856..70576d6bd 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -19,7 +19,6 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" - "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -692,12 +691,6 @@ func (m *Manager) initiateLoopIn(ctx context.Context, err) } - // Check that the label is valid. - err = labels.Validate(req.Label) - if err != nil { - return nil, fmt.Errorf("invalid label: %w", err) - } - // Private and route hints are mutually exclusive as setting private // means we retrieve our own route hints from the connected node. if len(req.RouteHints) != 0 && req.Private { diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index d908a9e16..cb3b6f07b 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -2,15 +2,21 @@ package loopin import ( "context" + "errors" "testing" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/swap" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -176,8 +182,50 @@ func TestSelectDeposits(t *testing.T) { } } +// TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal +// loop-in manager path does not reject reserved autoloop labels. The RPC +// boundary owns that validation, while internal autoloop dispatch must be able +// to reuse the reserved labels directly. +func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { + ctx := t.Context() + + const confirmationHeight = 0 + selectedDeposit := makeDeposit(1, 0, 9_000, confirmationHeight) + selectedOutpoint := selectedDeposit.OutPoint.String() + quoteErr := errors.New("quote failed") + quoteGetter := &mockQuoteGetter{ + err: quoteErr, + } + + manager, err := NewManager(&Config{ + DepositManager: &mockDepositManager{ + byOutpoint: map[string]*deposit.Deposit{ + selectedOutpoint: selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + _, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{ + DepositOutpoints: []string{selectedOutpoint}, + SelectedAmount: selectedDeposit.Value, + MaxSwapFee: 1_000, + Label: labels.AutoloopLabel(swap.TypeIn), + Initiator: "autoloop", + }) + require.ErrorIs(t, err, quoteErr) + require.NotContains(t, err.Error(), labels.ErrReservedPrefix.Error()) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { + // activeDeposits is the set returned by GetActiveDepositsInState. + activeDeposits []*deposit.Deposit + + // byOutpoint maps outpoint strings to deposits for direct lookups. byOutpoint map[string]*deposit.Deposit } @@ -187,10 +235,28 @@ func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( return nil, nil } -func (m *mockDepositManager) AllStringOutpointsActiveDeposits(_ []string, - _ fsm.StateType) ([]*deposit.Deposit, bool) { +func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string, + state fsm.StateType) ([]*deposit.Deposit, bool) { + + if state != deposit.Deposited { + return nil, false + } + + if m.byOutpoint == nil { + return nil, false + } + + res := make([]*deposit.Deposit, 0, len(outpoints)) + for _, outpoint := range outpoints { + selectedDeposit, ok := m.byOutpoint[outpoint] + if !ok { + return nil, false + } + + res = append(res, selectedDeposit) + } - return nil, false + return res, true } func (m *mockDepositManager) TransitionDeposits(_ context.Context, @@ -214,7 +280,52 @@ func (m *mockDepositManager) DepositsForOutpoints(_ context.Context, func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( []*deposit.Deposit, error) { - return nil, nil + return m.activeDeposits, nil +} + +// mockQuoteGetter records the inputs to quote requests and returns a fixed +// loop-in quote. +type mockQuoteGetter struct { + // quote is the response returned from GetLoopInQuote. + quote *loop.LoopInQuote + + // err is the optional error returned from GetLoopInQuote. + err error + + // amount records the quoted amount. + amount btcutil.Amount + + // lastHop records the quoted last hop. + lastHop *route.Vertex + + // initiator records the quoted initiator string. + initiator string + + // numDeposits records the quoted deposit count. + numDeposits uint32 + + // fast records the quoted fast flag. + fast bool +} + +// GetLoopInQuote returns the configured quote and records the request +// parameters for assertions. +func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, + amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex, + _ [][]zpay32.HopHint, initiator string, numDeposits uint32, + fast bool) (*loop.LoopInQuote, error) { + + m.amount = amt + m.lastHop = lastHop + m.initiator = initiator + m.numDeposits = numDeposits + m.fast = fast + + if m.err != nil { + return nil, m.err + } + + return m.quote, nil } // mockStore implements StaticAddressLoopInStore for tests. @@ -276,8 +387,14 @@ func (s *mockStore) SwapHashesForDepositIDs(_ context.Context, } // helper to create a deposit with specific outpoint and value. -func makeDeposit(h byte, index uint32, value btcutil.Amount) *deposit.Deposit { - d := &deposit.Deposit{Value: value} +func makeDeposit(h byte, index uint32, value btcutil.Amount, + confirmationHeight int64) *deposit.Deposit { + + d := &deposit.Deposit{ + Value: value, + ConfirmationHeight: confirmationHeight, + } + d.Hash = chainhash.Hash{h} d.Index = index var id deposit.ID @@ -330,11 +447,12 @@ func TestCheckChange(t *testing.T) { } // Deposits belonging to different swaps. - s1d1 := makeDeposit(1, 0, 1000) - s1d2 := makeDeposit(1, 1, 2000) - s2d1 := makeDeposit(2, 0, 1500) - s3d1 := makeDeposit(3, 0, 800) - s4d1 := makeDeposit(4, 0, 900) + const confirmationHeight = 0 + s1d1 := makeDeposit(1, 0, 1000, confirmationHeight) + s1d2 := makeDeposit(1, 1, 2000, confirmationHeight) + s2d1 := makeDeposit(2, 0, 1500, confirmationHeight) + s3d1 := makeDeposit(3, 0, 800, confirmationHeight) + s4d1 := makeDeposit(4, 0, 900, confirmationHeight) // Swaps: // A: total 3000, selected 3000 => no change. diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 1b70bbc48..d06c5c181 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -591,6 +591,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, if len(updates) > 0 { lastUpdate := updates[len(updates)-1] loopIn.SetState(fsm.StateType(lastUpdate.UpdateState)) + loopIn.LastUpdateTime = lastUpdate.UpdateTimestamp } return loopIn, nil diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 356049bc7..1e30081dc 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -159,7 +159,7 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { // StaticAddressLoopIn swap and associates it with the provided deposits. func TestCreateLoopIn(t *testing.T) { // Set up test context objects. - ctxb := context.Background() + ctx := t.Context() testDb := loopdb.NewTestDB(t) testClock := clock.NewTestClock(time.Now()) defer testDb.Close() @@ -200,17 +200,17 @@ func TestCreateLoopIn(t *testing.T) { }, } - err := depositStore.CreateDeposit(ctxb, d1) + err := depositStore.CreateDeposit(ctx, d1) require.NoError(t, err) - err = depositStore.CreateDeposit(ctxb, d2) + err = depositStore.CreateDeposit(ctx, d2) require.NoError(t, err) d1.SetState(deposit.LoopingIn) d2.SetState(deposit.LoopingIn) - err = depositStore.UpdateDeposit(ctxb, d1) + err = depositStore.UpdateDeposit(ctx, d1) require.NoError(t, err) - err = depositStore.UpdateDeposit(ctxb, d2) + err = depositStore.UpdateDeposit(ctx, d2) require.NoError(t, err) _, clientPubKey := test.CreateKey(1) @@ -232,11 +232,11 @@ func TestCreateLoopIn(t *testing.T) { } swapPending.SetState(SignHtlcTx) - err = swapStore.CreateLoopIn(ctxb, &swapPending) + err = swapStore.CreateLoopIn(ctx, &swapPending) require.NoError(t, err) depositIDs, err := swapStore.DepositIDsForSwapHash( - ctxb, swapHashPending, + ctx, swapHashPending, ) require.NoError(t, err) require.Len(t, depositIDs, 2) @@ -244,7 +244,7 @@ func TestCreateLoopIn(t *testing.T) { require.Contains(t, depositIDs, d2.ID) swapHashes, err := swapStore.SwapHashesForDepositIDs( - ctxb, []deposit.ID{depositIDs[0], depositIDs[1]}, + ctx, []deposit.ID{depositIDs[0], depositIDs[1]}, ) require.NoError(t, err) require.Len(t, swapHashes, 1) @@ -252,7 +252,7 @@ func TestCreateLoopIn(t *testing.T) { require.Contains(t, swapHashes[swapHashPending], depositIDs[0]) require.Contains(t, swapHashes[swapHashPending], depositIDs[1]) - swap, err := swapStore.GetLoopInByHash(ctxb, swapHashPending) + swap, err := swapStore.GetLoopInByHash(ctx, swapHashPending) require.NoError(t, err) require.Equal(t, swapHashPending, swap.SwapHash) require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()}, @@ -270,4 +270,19 @@ func TestCreateLoopIn(t *testing.T) { require.Equal(t, d2.OutPoint, swap.Deposits[1].OutPoint) require.Equal(t, d2.Value, swap.Deposits[1].Value) require.Equal(t, deposit.LoopingIn, swap.Deposits[1].GetState()) + + updateTime := testClock.Now().Add(time.Minute) + testClock.SetTime(updateTime) + swapPending.SetState(Succeeded) + + err = swapStore.UpdateLoopIn(ctx, &swapPending) + require.NoError(t, err) + + swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending) + require.NoError(t, err) + require.Equal(t, Succeeded, swap.GetState()) + require.WithinDuration( + t, updateTime.UTC(), swap.LastUpdateTime.UTC(), + time.Microsecond, + ) }