// Package liquidity is responsible for monitoring our node's liquidity. It // allows setting of a liquidity rule which describes the desired liquidity // balance on a per-channel basis. // // Swap suggestions are limited to channels that are not currently being used // for a pending swap. If we are currently processing an unrestricted swap (ie, // a loop out with no outgoing channel targets set or a loop in with no last // hop set), we will not suggest any swaps because these swaps will shift the // balances of our channels in ways we can't predict. // // Fee restrictions are placed on swap suggestions to ensure that we only // suggest swaps that fit the configured fee preferences. // - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep // transaction to confirm within our configured number of confirmations // that we will suggest swaps for. // - Maximum Swap Fee PPM: the maximum server fee, expressed as parts per // million of the full swap amount // - Maximum Routing Fee PPM: the maximum off-chain routing fees for the swap // invoice, expressed as parts per million of the swap amount. // - Maximum Prepay Routing Fee PPM: the maximum off-chain routing fees for the // swap prepayment, expressed as parts per million of the prepay amount. // - Maximum Prepay: the maximum now-show fee, expressed in satoshis. This // amount is only payable in the case where the swap server broadcasts a htlc // and the client fails to sweep the preimage. // - Maximum miner fee: the maximum miner fee we are willing to pay to sweep the // on chain htlc. Note that the client will use current fee estimates to // sweep, so this value acts more as a sanity check in the case of a large fee // spike. // // The maximum fee per-swap is calculated as follows: // (swap amount * serverPPM/1e6) + miner fee + (swap amount * routingPPM/1e6) // + (prepay amount * prepayPPM/1e6). package liquidity import ( "context" "errors" "fmt" "sort" "strings" "sync" "time" "github.com/btcsuite/btcutil" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/swap" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/funding" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/ticker" ) const ( // defaultFailureBackoff is the default amount of time we backoff if // a channel is part of a temporarily failed swap. defaultFailureBackoff = time.Hour * 24 // defaultConfTarget is the default sweep target we use for loop outs. // We get our inbound liquidity quickly using preimage push, so we can // use a long conf target without worrying about ux impact. defaultConfTarget = 100 // FeeBase is the base that we use to express fees. FeeBase = 1e6 // defaultMaxInFlight is the default number of in-flight automatically // dispatched swaps we allow. Note that this does not enable automated // swaps itself (because we want non-zero values to be expressed in // suggestions as a dry-run). defaultMaxInFlight = 1 // DefaultAutoloopTicker is the default amount of time between automated // swap checks. DefaultAutoloopTicker = time.Minute * 10 // autoloopSwapInitiator is the value we send in the initiator field of // a swap request when issuing an automatic swap. autoloopSwapInitiator = "autoloop" ) var ( // defaultBudget is the default autoloop budget we set. This budget will // only be used for automatically dispatched swaps if autoloop is // explicitly enabled, so we are happy to set a non-zero value here. The // amount chosen simply uses the current defaults to provide budget for // a single swap. We don't have a swap amount so we just use our max // funding amount. defaultBudget = ppmToSat(funding.MaxBtcFundingAmount, defaultFeePPM) // defaultParameters contains the default parameters that we start our // liquidity manger with. defaultParameters = Parameters{ AutoFeeBudget: defaultBudget, MaxAutoInFlight: defaultMaxInFlight, ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), PeerRules: make(map[route.Vertex]*ThresholdRule), FailureBackOff: defaultFailureBackoff, SweepConfTarget: defaultConfTarget, FeeLimit: defaultFeePortion(), } // ErrZeroChannelID is returned if we get a rule for a 0 channel ID. ErrZeroChannelID = fmt.Errorf("zero channel ID not allowed") // ErrNegativeBudget is returned if a negative swap budget is set. ErrNegativeBudget = errors.New("swap budget must be >= 0") // ErrZeroInFlight is returned is a zero in flight swaps value is set. ErrZeroInFlight = errors.New("max in flight swaps must be >=0") // ErrMinimumExceedsMaximumAmt is returned when the minimum configured // swap amount is more than the maximum. ErrMinimumExceedsMaximumAmt = errors.New("minimum swap amount " + "exceeds maximum") // ErrMaxExceedsServer is returned if the maximum swap amount set is // more than the server offers. ErrMaxExceedsServer = errors.New("maximum swap amount is more than " + "server maximum") // ErrMinLessThanServer is returned if the minimum swap amount set is // less than the server minimum. ErrMinLessThanServer = errors.New("minimum swap amount is less than " + "server minimum") // ErrNoRules is returned when no rules are set for swap suggestions. ErrNoRules = errors.New("no rules set for autoloop") // ErrExclusiveRules is returned when a set of rules that may not be // set together are specified. ErrExclusiveRules = errors.New("channel and peer rules must be " + "exclusive") ) // Config contains the external functionality required to run the // liquidity manager. type Config struct { // AutoloopTicker determines how often we should check whether we want // to dispatch an automated swap. We use a force ticker so that we can // trigger autoloop in itests. AutoloopTicker *ticker.Force // Restrictions returns the restrictions that the server applies to // swaps. Restrictions func(ctx context.Context, swapType swap.Type) ( *Restrictions, error) // Lnd provides us with access to lnd's rpc servers. Lnd *lndclient.LndServices // ListLoopOut returns all of the loop our swaps stored on disk. ListLoopOut func() ([]*loopdb.LoopOut, error) // ListLoopIn returns all of the loop in swaps stored on disk. ListLoopIn func() ([]*loopdb.LoopIn, error) // LoopOutQuote gets swap fee, estimated miner fee and prepay amount for // a loop out swap. LoopOutQuote func(ctx context.Context, request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) // LoopOut dispatches a loop out. LoopOut func(ctx context.Context, request *loop.OutRequest) ( *loop.LoopOutSwapInfo, error) // Clock allows easy mocking of time in unit tests. Clock clock.Clock // MinimumConfirmations is the minimum number of confirmations we allow // setting for sweep target. MinimumConfirmations int32 } // Parameters is a set of parameters provided by the user which guide // how we assess liquidity. type Parameters struct { // Autoloop enables automatic dispatch of swaps. Autoloop bool // AutoFeeBudget is the total amount we allow to be spent on // automatically dispatched swaps. Once this budget has been used, we // will stop dispatching swaps until the budget is increased or the // start date is moved. AutoFeeBudget btcutil.Amount // AutoFeeStartDate is the date from which we will include automatically // dispatched swaps in our current budget, inclusive. AutoFeeStartDate time.Time // MaxAutoInFlight is the maximum number of in-flight automatically // dispatched swaps we allow. MaxAutoInFlight int // FailureBackOff is the amount of time that we require passes after a // channel has been part of a failed loop out swap before we suggest // using it again. // TODO(carla): add exponential backoff FailureBackOff time.Duration // SweepConfTarget is the number of blocks we aim to confirm our sweep // transaction in. This value affects the on chain fees we will pay. SweepConfTarget int32 // FeeLimit controls the fee limit we place on swaps. FeeLimit FeeLimit // ClientRestrictions are the restrictions placed on swap size by the // client. ClientRestrictions Restrictions // ChannelRules maps a short channel ID to a rule that describes how we // would like liquidity to be managed. These rules and PeerRules are // exclusively set to prevent overlap between peer and channel rules. ChannelRules map[lnwire.ShortChannelID]*ThresholdRule // PeerRules maps a peer's pubkey to a rule that applies to all the // channels that we have with the peer collectively. These rules and // ChannelRules are exclusively set to prevent overlap between peer // and channel rules map to avoid ambiguity. PeerRules map[route.Vertex]*ThresholdRule } // String returns the string representation of our parameters. func (p Parameters) String() string { ruleList := make([]string, 0, len(p.ChannelRules)+len(p.PeerRules)) for channel, rule := range p.ChannelRules { ruleList = append( ruleList, fmt.Sprintf("Channel: %v: %v", channel, rule), ) } for peer, rule := range p.PeerRules { ruleList = append( ruleList, fmt.Sprintf("Peer: %v: %v", peer, rule), ) } return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ "sweep conf target: %v, fees: %v, auto budget: %v, budget "+ "start: %v, max auto in flight: %v, minimum swap size=%v, "+ "maximum swap size=%v", strings.Join(ruleList, ","), p.FailureBackOff, p.SweepConfTarget, p.FeeLimit, p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) } // haveRules returns a boolean indicating whether we have any rules configured. func (p Parameters) haveRules() bool { if len(p.ChannelRules) != 0 { return true } if len(p.PeerRules) != 0 { return true } return false } // validate checks whether a set of parameters is valid. Our set of currently // open channels are required to check that there is no overlap between the // rules set on a per-peer level, and those set for specific channels. We can't // allow both, because then we're trying to cater for two separate liquidity // goals on the same channel. Since we use short channel ID, we don't need to // worry about pending channels (users would need to work very hard to get the // short channel ID for a pending channel). Likewise, we don't care about closed // channels, since there is no action that may occur on them, and we want to // allow peer-level rules to be set once a channel which had a specific rule // has been closed. It takes the minimum confirmations we allow for sweep // confirmation target as a parameter. // TODO(carla): prune channels that have been closed from rules. func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, server *Restrictions) error { // First, we check that the rules on a per peer and per channel do not // overlap, since this could lead to contractions. for _, channel := range openChans { // If we don't have a rule for the peer, there's no way we have // an overlap between this peer and the channel. _, ok := p.PeerRules[channel.PubKeyBytes] if !ok { continue } shortID := lnwire.NewShortChanIDFromInt(channel.ChannelID) _, ok = p.ChannelRules[shortID] if ok { log.Debugf("Rules for peer: %v and its channel: %v "+ "can't both be set", channel.PubKeyBytes, shortID) return ErrExclusiveRules } } for channel, rule := range p.ChannelRules { if channel.ToUint64() == 0 { return ErrZeroChannelID } if err := rule.validate(); err != nil { return fmt.Errorf("channel: %v has invalid rule: %v", channel.ToUint64(), err) } } for peer, rule := range p.PeerRules { if err := rule.validate(); err != nil { return fmt.Errorf("peer: %v has invalid rule: %v", peer, err) } } // Check that our confirmation target is above our required minimum. if p.SweepConfTarget < minConfs { return fmt.Errorf("confirmation target must be at least: %v", minConfs) } if err := p.FeeLimit.validate(); err != nil { return err } if p.AutoFeeBudget < 0 { return ErrNegativeBudget } if p.MaxAutoInFlight <= 0 { return ErrZeroInFlight } err := validateRestrictions(server, &p.ClientRestrictions) if err != nil { return err } return nil } // validateRestrictions checks that client restrictions fall within the server's // restrictions. func validateRestrictions(server, client *Restrictions) error { zeroMin := client.Minimum == 0 zeroMax := client.Maximum == 0 if zeroMin && zeroMax { return nil } // If we have a non-zero maximum, we need to ensure it is greater than // our minimum (which is fine if min is zero), and does not exceed the // server's maximum. if !zeroMax { if client.Minimum > client.Maximum { return ErrMinimumExceedsMaximumAmt } if client.Maximum > server.Maximum { return ErrMaxExceedsServer } } if zeroMin { return nil } // If the client set a minimum, ensure it is at least equal to the // server's limit. if client.Minimum < server.Minimum { return ErrMinLessThanServer } return nil } // Manager contains a set of desired liquidity rules for our channel // balances. type Manager struct { // cfg contains the external functionality we require to determine our // current liquidity balance. cfg *Config // params is the set of parameters we are currently using. These may be // updated at runtime. params Parameters // paramsLock is a lock for our current set of parameters. paramsLock sync.Mutex } // Run periodically checks whether we should automatically dispatch a loop out. // We run this loop even if automated swaps are not currently enabled rather // than managing starting and stopping the ticker as our parameters are updated. func (m *Manager) Run(ctx context.Context) error { m.cfg.AutoloopTicker.Resume() defer m.cfg.AutoloopTicker.Stop() for { select { case <-m.cfg.AutoloopTicker.Ticks(): err := m.autoloop(ctx) switch err { case ErrNoRules: log.Debugf("No rules configured for autoloop") case nil: default: log.Errorf("autoloop failed: %v", err) } case <-ctx.Done(): return ctx.Err() } } } // NewManager creates a liquidity manager which has no rules set. func NewManager(cfg *Config) *Manager { return &Manager{ cfg: cfg, params: defaultParameters, } } // GetParameters returns a copy of our current parameters. func (m *Manager) GetParameters() Parameters { m.paramsLock.Lock() defer m.paramsLock.Unlock() return cloneParameters(m.params) } // SetParameters updates our current set of parameters if the new parameters // provided are valid. func (m *Manager) SetParameters(ctx context.Context, params Parameters) error { restrictions, err := m.cfg.Restrictions(ctx, swap.TypeOut) if err != nil { return err } channels, err := m.cfg.Lnd.Client.ListChannels(ctx) if err != nil { return err } err = params.validate(m.cfg.MinimumConfirmations, channels, restrictions) if err != nil { return err } m.paramsLock.Lock() defer m.paramsLock.Unlock() m.params = cloneParameters(params) return nil } // cloneParameters creates a deep clone of a parameters struct so that callers // cannot mutate our parameters. Although our parameters struct itself is not // a reference, we still need to clone the contents of maps. func cloneParameters(params Parameters) Parameters { paramCopy := params paramCopy.ChannelRules = make( map[lnwire.ShortChannelID]*ThresholdRule, len(params.ChannelRules), ) for channel, rule := range params.ChannelRules { ruleCopy := *rule paramCopy.ChannelRules[channel] = &ruleCopy } paramCopy.PeerRules = make( map[route.Vertex]*ThresholdRule, len(params.PeerRules), ) for peer, rule := range params.PeerRules { ruleCopy := *rule paramCopy.PeerRules[peer] = &ruleCopy } return paramCopy } // autoloop gets a set of suggested swaps and dispatches them automatically if // we have automated looping enabled. func (m *Manager) autoloop(ctx context.Context) error { suggestion, err := m.SuggestSwaps(ctx, true) if err != nil { return err } for _, swap := range suggestion.OutSwaps { // If we don't actually have dispatch of swaps enabled, log // suggestions. if !m.params.Autoloop { log.Debugf("recommended autoloop: %v sats over "+ "%v", swap.Amount, swap.OutgoingChanSet) continue } // Create a copy of our range var so that we can reference it. swap := swap loopOut, err := m.cfg.LoopOut(ctx, &swap) if err != nil { return err } log.Infof("loop out automatically dispatched: hash: %v, "+ "address: %v", loopOut.SwapHash, loopOut.HtlcAddressP2WSH) } return nil } // ForceAutoLoop force-ticks our auto-out ticker. func (m *Manager) ForceAutoLoop(ctx context.Context) error { select { case m.cfg.AutoloopTicker.Force <- m.cfg.Clock.Now(): return nil case <-ctx.Done(): return ctx.Err() } } // Suggestions provides a set of suggested swaps, and the set of channels that // were excluded from consideration. type Suggestions struct { // OutSwaps is the set of loop out swaps that we suggest executing. OutSwaps []loop.OutRequest // 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 // Disqualified peers maps the set of peers that we do not recommend // swaps for to the reason that they were excluded. DisqualifiedPeers map[route.Vertex]Reason } func newSuggestions() *Suggestions { return &Suggestions{ DisqualifiedChans: make(map[lnwire.ShortChannelID]Reason), DisqualifiedPeers: make(map[route.Vertex]Reason), } } func (s *Suggestions) addSwap(swap swapSuggestion) error { out, ok := swap.(*loopOutSwapSuggestion) if !ok { return fmt.Errorf("unexpected swap type: %T", swap) } s.OutSwaps = append(s.OutSwaps, out.OutRequest) return nil } // 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). func (m *Manager) singleReasonSuggestion(reason Reason) *Suggestions { resp := newSuggestions() for id := range m.params.ChannelRules { resp.DisqualifiedChans[id] = reason } for peer := range m.params.PeerRules { resp.DisqualifiedPeers[peer] = reason } 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. func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( *Suggestions, error) { m.paramsLock.Lock() defer m.paramsLock.Unlock() // If we have no rules set, exit early to avoid unnecessary calls to // lnd and the server. if !m.params.haveRules() { return nil, ErrNoRules } // If our start date is in the future, we interpret this as meaning that // we should start using our budget at this date. This means that we // have no budget for the present, so we just return. if m.params.AutoFeeStartDate.After(m.cfg.Clock.Now()) { log.Debugf("autoloop fee budget start time: %v is in "+ "the future", m.params.AutoFeeStartDate) return m.singleReasonSuggestion(ReasonBudgetNotStarted), nil } // Before we get any swap suggestions, we check what the current fee // estimate is to sweep within our target number of confirmations. If // This fee exceeds the fee limit we have set, we will not suggest any // swaps at present. estimate, err := m.cfg.Lnd.WalletKit.EstimateFee( ctx, m.params.SweepConfTarget, ) if err != nil { return nil, err } if err := m.params.FeeLimit.mayLoopOut(estimate); err != nil { var reasonErr *reasonError if errors.As(err, &reasonErr) { return m.singleReasonSuggestion(reasonErr.reason), nil } return nil, err } // Get the current server side restrictions, combined with the client // set restrictions, if any. restrictions, err := m.getSwapRestrictions(ctx, swap.TypeOut) if err != nil { return nil, err } // List our current set of swaps so that we can determine which channels // are already being utilized by swaps. Note that these calls may race // with manual initiation of swaps. loopOut, err := m.cfg.ListLoopOut() if err != nil { return nil, err } loopIn, err := m.cfg.ListLoopIn() if err != nil { return nil, err } // Get a summary of our existing swaps so that we can check our autoloop // budget. summary, err := m.checkExistingAutoLoops(ctx, loopOut) if err != nil { return nil, err } if summary.totalFees() >= m.params.AutoFeeBudget { log.Debugf("autoloop fee budget: %v exhausted, %v spent on "+ "completed swaps, %v reserved for ongoing swaps "+ "(upper limit)", m.params.AutoFeeBudget, summary.spentFees, summary.pendingFees) return m.singleReasonSuggestion(ReasonBudgetElapsed), nil } // If we have already reached our total allowed number of in flight // swaps, we do not suggest any more at the moment. allowedSwaps := m.params.MaxAutoInFlight - summary.inFlightCount if allowedSwaps <= 0 { log.Debugf("%v autoloops allowed, %v in flight", m.params.MaxAutoInFlight, summary.inFlightCount) return m.singleReasonSuggestion(ReasonInFlight), nil } channels, err := m.cfg.Lnd.Client.ListChannels(ctx) if err != nil { return nil, err } // Collect a map of channel IDs to peer pubkeys, and a set of per-peer // balances which we will use for peer-level liquidity rules. channelPeers := make(map[uint64]route.Vertex) peerChannels := make(map[route.Vertex]*balances) for _, channel := range channels { channelPeers[channel.ChannelID] = channel.PubKeyBytes bal, ok := peerChannels[channel.PubKeyBytes] if !ok { bal = &balances{} } chanID := lnwire.NewShortChanIDFromInt(channel.ChannelID) bal.channels = append(bal.channels, chanID) bal.capacity += channel.Capacity bal.incoming += channel.RemoteBalance bal.outgoing += channel.LocalBalance bal.pubkey = channel.PubKeyBytes peerChannels[channel.PubKeyBytes] = bal } // Get a summary of the channels and peers that are not eligible due // to ongoing swaps. traffic := m.currentSwapTraffic(loopOut, loopIn) var ( suggestions []swapSuggestion resp = newSuggestions() ) for peer, balances := range peerChannels { rule, haveRule := m.params.PeerRules[peer] if !haveRule { continue } suggestion, err := m.suggestSwap( ctx, traffic, balances, rule, restrictions, autoloop, ) var reasonErr *reasonError if errors.As(err, &reasonErr) { resp.DisqualifiedPeers[peer] = reasonErr.reason continue } if err != nil { return nil, err } suggestions = append(suggestions, suggestion) } for _, channel := range channels { balance := newBalances(channel) channelID := lnwire.NewShortChanIDFromInt(channel.ChannelID) rule, ok := m.params.ChannelRules[channelID] if !ok { continue } suggestion, err := m.suggestSwap( ctx, traffic, balance, rule, restrictions, autoloop, ) var reasonErr *reasonError if errors.As(err, &reasonErr) { resp.DisqualifiedChans[channelID] = reasonErr.reason continue } if err != nil { return nil, err } suggestions = append(suggestions, 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 { 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() }) // Run through our suggested swaps in descending order of amount and // return all of the swaps which will fit within our remaining budget. available := m.params.AutoFeeBudget - summary.totalFees() // 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) { _, ok := m.params.PeerRules[peer] if !ok { continue } resp.DisqualifiedPeers[peer] = reason } for _, channel := range swap.channels() { _, ok := m.params.ChannelRules[channel] if !ok { continue } resp.DisqualifiedChans[channel] = reason } } for _, swap := range suggestions { swap := swap // 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. var reason Reason switch { case available == 0: reason = ReasonBudgetInsufficient case len(resp.OutSwaps) == allowedSwaps: reason = ReasonInFlight } if reason != ReasonNone { setReason(reason, swap) continue } fees := swap.fees() // If the maximum fee we expect our swap to use is less than the // amount we have available, we add it to our set of swaps that // fall within the budget and decrement our available amount. if fees <= available { available -= fees if err := resp.addSwap(swap); err != nil { return nil, err } } else { setReason(ReasonBudgetInsufficient, swap) } } return resp, 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 *ThresholdRule, restrictions *Restrictions, autoloop bool) (swapSuggestion, error) { // Check whether we can perform a swap. err := traffic.maySwap(balance.pubkey, balance.channels) if err != nil { return nil, err } // We can have nil suggestions in the case where no action is // required, so we skip over them. amount := rule.swapAmount(balance, restrictions) if amount == 0 { return nil, newReasonError(ReasonLiquidityOk) } swap, err := m.loopOutSwap(ctx, amount, balance, autoloop) if err != nil { return nil, err } return &loopOutSwapSuggestion{ OutRequest: *swap, }, nil } // loopOutSwap creates a loop out swap with the amount provided for the balance // described by the balance set provided. A reason that indicates whether we // can swap is returned. If this value is not ReasonNone, there is no possible // swap and the loop out request returned will be nil. func (m *Manager) loopOutSwap(ctx context.Context, amount btcutil.Amount, balance *balances, autoloop bool) (*loop.OutRequest, error) { quote, err := m.cfg.LoopOutQuote( ctx, &loop.LoopOutQuoteRequest{ Amount: amount, SweepConfTarget: m.params.SweepConfTarget, SwapPublicationDeadline: m.cfg.Clock.Now(), }, ) if err != nil { return nil, err } log.Debugf("quote for suggestion: %v, swap fee: %v, "+ "miner fee: %v, prepay: %v", amount, quote.SwapFee, quote.MinerFee, quote.PrepayAmount) // Check that the estimated fees for the suggested swap are // below the fee limits configured by the manager. if err := m.params.FeeLimit.loopOutLimits(amount, quote); err != nil { return nil, err } outRequest, err := m.makeLoopOutRequest( ctx, amount, balance, quote, autoloop, ) if err != nil { return nil, err } return &outRequest, nil } // getSwapRestrictions queries the server for its latest swap size restrictions, // validates client restrictions (if present) against these values and merges // the client's custom requirements with the server's limits to produce a single // set of limitations for our swap. func (m *Manager) getSwapRestrictions(ctx context.Context, swapType swap.Type) ( *Restrictions, error) { restrictions, err := m.cfg.Restrictions(ctx, swapType) if err != nil { return nil, err } // It is possible that the server has updated its restrictions since // we validated our client restrictions, so we validate again to ensure // that our restrictions are within the server's bounds. err = validateRestrictions(restrictions, &m.params.ClientRestrictions) if err != nil { return nil, err } // If our minimum is more than the server's minimum, we set it. if m.params.ClientRestrictions.Minimum > restrictions.Minimum { restrictions.Minimum = m.params.ClientRestrictions.Minimum } // If our maximum set and is less than the server's maximum, we set it. if m.params.ClientRestrictions.Maximum != 0 && m.params.ClientRestrictions.Maximum < restrictions.Maximum { restrictions.Maximum = m.params.ClientRestrictions.Maximum } return restrictions, nil } // makeLoopOutRequest creates a loop out request from a suggestion. Since we // do not get any information about our off-chain routing fees when we request // a quote, we just set our prepay and route maximum fees directly from the // amounts we expect to route. The estimation we use elsewhere is the repo is // route-independent, which is a very poor estimation so we don't bother with // checking against this inaccurate constant. We use the exact prepay amount // and swap fee given to us by the server, but use our maximum miner fee anyway // to give us some leeway when performing the swap. We take an auto-out which // determines whether we set a label identifying this swap as automatically // dispatched, and decides whether we set a sweep address (we don't bother for // non-auto requests, because the client api will set it anyway). func (m *Manager) makeLoopOutRequest(ctx context.Context, amount btcutil.Amount, balance *balances, quote *loop.LoopOutQuote, autoloop bool) (loop.OutRequest, error) { prepayMaxFee, routeMaxFee, minerFee := m.params.FeeLimit.loopOutFees( amount, quote, ) var chanSet loopdb.ChannelSet for _, channel := range balance.channels { chanSet = append(chanSet, channel.ToUint64()) } // Create a request with our calculated routing fees. We can use the // swap fee, prepay amount and miner fee from the quote because we have // already validated them. request := loop.OutRequest{ Amount: amount, OutgoingChanSet: chanSet, MaxPrepayRoutingFee: prepayMaxFee, MaxSwapRoutingFee: routeMaxFee, MaxMinerFee: minerFee, MaxSwapFee: quote.SwapFee, MaxPrepayAmount: quote.PrepayAmount, SweepConfTarget: m.params.SweepConfTarget, Initiator: autoloopSwapInitiator, } if autoloop { request.Label = labels.AutoloopLabel(swap.TypeOut) addr, err := m.cfg.Lnd.WalletKit.NextAddr(ctx) if err != nil { return loop.OutRequest{}, err } request.DestAddr = addr } return request, nil } // worstCaseOutFees calculates the largest possible fees for a loop out swap, // comparing the fees for a successful swap to the cost when the client pays // the prepay because they failed to sweep the on chain htlc. This is unlikely, // because we expect clients to be online to sweep, but we want to account for // every outcome so we include it. func worstCaseOutFees(prepayRouting, swapRouting, swapFee, minerFee, prepayAmount btcutil.Amount) btcutil.Amount { var ( successFees = prepayRouting + minerFee + swapFee + swapRouting noShowFees = prepayRouting + prepayAmount ) if noShowFees > successFees { return noShowFees } return successFees } // existingAutoLoopSummary provides a summary of the existing autoloops which // were dispatched during our current budget period. type existingAutoLoopSummary struct { // spentFees is the amount we have spent on completed swaps. spentFees btcutil.Amount // pendingFees is the worst-case amount of fees we could spend on in // flight autoloops. pendingFees btcutil.Amount // inFlightCount is the total number of automated swaps that are // currently in flight. Note that this may race with swap completion, // but not with initiation of new automated swaps, this is ok, because // it can only lead to dispatching fewer swaps than we could have (not // too many). inFlightCount int } // totalFees returns the total amount of fees that automatically dispatched // swaps may consume. func (e *existingAutoLoopSummary) totalFees() btcutil.Amount { return e.spentFees + e.pendingFees } // 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(ctx context.Context, loopOuts []*loopdb.LoopOut) (*existingAutoLoopSummary, error) { var summary existingAutoLoopSummary for _, out := range loopOuts { if out.Contract.Label != labels.AutoloopLabel(swap.TypeOut) { continue } // If we have a pending swap, we are uncertain of the fees that // it will end up paying. We use the worst-case estimate based // on the maximum values we set for each fee category. This will // likely over-estimate our fees (because we probably won't // spend our maximum miner amount). If a swap is not pending, // it has succeeded or failed so we just record our actual fees // for the swap provided that the swap completed after our // budget start date. if out.State().State.Type() == loopdb.StateTypePending { summary.inFlightCount++ prepay, err := m.cfg.Lnd.Client.DecodePaymentRequest( ctx, out.Contract.PrepayInvoice, ) if err != nil { return nil, err } summary.pendingFees += worstCaseOutFees( out.Contract.MaxPrepayRoutingFee, out.Contract.MaxSwapRoutingFee, out.Contract.MaxSwapFee, out.Contract.MaxMinerFee, mSatToSatoshis(prepay.Value), ) } else if !out.LastUpdateTime().Before(m.params.AutoFeeStartDate) { summary.spentFees += out.State().Cost.Total() } } return &summary, nil } // 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 { traffic := newSwapTraffic() // Failure cutoff is the most recent failure timestamp we will still // consider a channel eligible. Any channels involved in swaps that have // failed since this point will not be considered. failureCutoff := m.cfg.Clock.Now().Add(m.params.FailureBackOff * -1) for _, out := range loopOut { var ( state = out.State().State chanSet = out.Contract.OutgoingChanSet ) // If a loop out swap failed due to off chain payment after our // failure cutoff, we add all of its channels to a set of // recently failed channels. It is possible that not all of // these channels were used for the swap, but we play it safe // and back off for all of them. // // We only backoff for off temporary failures. In the case of // chain payment failures, our swap failed to route and we do // not want to repeatedly try to route through bad channels // which remain unbalanced because they cannot route a swap, so // we backoff. if state == loopdb.StateFailOffchainPayments { failedAt := out.LastUpdate().Time if failedAt.After(failureCutoff) { for _, id := range chanSet { chanID := lnwire.NewShortChanIDFromInt( id, ) traffic.failedLoopOut[chanID] = failedAt } } } // Skip completed swaps, they can't affect our channel balances. // Swaps that fail temporarily are considered to be in a pending // state, so we will also check that channels being used by // these swaps. This is important, because a temporarily failed // swap could be re-dispatched on restart, affecting our // balances. if state.Type() != loopdb.StateTypePending { continue } for _, id := range chanSet { chanID := lnwire.NewShortChanIDFromInt(id) traffic.ongoingLoopOut[chanID] = true } } for _, in := range loopIn { // Skip completed swaps, they can't affect our channel balances. if in.State().State.Type() != loopdb.StateTypePending { continue } // Skip over swaps that may come through any peer. if in.Contract.LastHop == nil { continue } traffic.ongoingLoopIn[*in.Contract.LastHop] = true } return traffic } // swapTraffic contains a summary of our current and previously failed swaps. type swapTraffic struct { ongoingLoopOut map[lnwire.ShortChannelID]bool ongoingLoopIn map[route.Vertex]bool failedLoopOut map[lnwire.ShortChannelID]time.Time } func newSwapTraffic() *swapTraffic { return &swapTraffic{ ongoingLoopOut: make(map[lnwire.ShortChannelID]bool), ongoingLoopIn: make(map[route.Vertex]bool), failedLoopOut: make(map[lnwire.ShortChannelID]time.Time), } } // maySwap returns a boolean that indicates whether we may perform a swap for a // peer and its set of channels. func (s *swapTraffic) maySwap(peer route.Vertex, channels []lnwire.ShortChannelID) error { for _, chanID := range channels { lastFail, recentFail := s.failedLoopOut[chanID] if recentFail { log.Debugf("Channel: %v not eligible for suggestions, was "+ "part of a failed swap at: %v", chanID, lastFail) return newReasonError(ReasonFailureBackoff) } if s.ongoingLoopOut[chanID] { log.Debugf("Channel: %v not eligible for suggestions, "+ "ongoing loop out utilizing channel", chanID) return newReasonError(ReasonLoopOut) } } if s.ongoingLoopIn[peer] { log.Debugf("Peer: %x not eligible for suggestions ongoing "+ "loop in utilizing peer", peer) return newReasonError(ReasonLoopIn) } return nil } // satPerKwToSatPerVByte converts sat per kWeight to sat per vByte. func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 { return int64(satPerKw.FeePerKVByte() / 1000) } // ppmToSat takes an amount and a measure of parts per million for the amount // and returns the amount that the ppm represents. func ppmToSat(amount btcutil.Amount, ppm uint64) btcutil.Amount { return btcutil.Amount(uint64(amount) * ppm / FeeBase) } func mSatToSatoshis(amount lnwire.MilliSatoshi) btcutil.Amount { return btcutil.Amount(amount / 1000) }