mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Static-address loop-ins in autoloop are still experimental. Reject loop_in_source=static-address at the RPC boundary unless loopd was started with --experimental, and pass the same opt-in into the liquidity manager so persisted params cannot bypass the gate after restart. The existing static swap accounting remains wired through the manager; the gate only controls accepting and planning new static-address autoloops.
2206 lines
64 KiB
Go
2206 lines
64 KiB
Go
// 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"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop"
|
|
"github.com/lightninglabs/loop/labels"
|
|
"github.com/lightninglabs/loop/loopdb"
|
|
clientrpc "github.com/lightninglabs/loop/looprpc"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/taproot-assets/rfqmsg"
|
|
"github.com/lightningnetwork/lnd/clock"
|
|
"github.com/lightningnetwork/lnd/funding"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/lightningnetwork/lnd/ticker"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const (
|
|
// defaultFailureBackoff is the default amount of time we backoff if
|
|
// a channel is part of a temporarily failed swap.
|
|
defaultFailureBackoff = time.Hour * 24
|
|
|
|
// defaultAmountBackoff is the default backoff we apply to the amount
|
|
// of a loop out swap that failed the off-chain payments.
|
|
defaultAmountBackoff = float64(0.25)
|
|
|
|
// defaultAmountBackoffRetry is the default number of times we will
|
|
// perform an amount backoff to a loop out swap before we give up.
|
|
defaultAmountBackoffRetry = 5
|
|
|
|
// defaultSwapWaitTimeout is the default maximum amount of time we
|
|
// wait for a swap to reach a terminal state.
|
|
defaultSwapWaitTimeout = time.Hour * 24
|
|
|
|
// defaultPaymentCheckInterval is the default time that passes between
|
|
// checks for loop out payments status.
|
|
defaultPaymentCheckInterval = time.Second * 2
|
|
|
|
// 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 * 20
|
|
|
|
// autoloopSwapInitiator is the value we send in the initiator field of
|
|
// a swap request when issuing an automatic swap.
|
|
autoloopSwapInitiator = "autoloop"
|
|
|
|
// We use a static fee rate to estimate our sweep fee, because we
|
|
// can't realistically estimate what our fee estimate will be by the
|
|
// time we reach timeout. We set this to a high estimate so that we can
|
|
// account for worst-case fees, (1250 * 4 / 1000) = 50 sat/byte.
|
|
defaultLoopInSweepFee = chainfee.SatPerKWeight(1250)
|
|
|
|
// defaultHtlcConfTarget is the default confirmation target we use for
|
|
// loop in swap htlcs, set to the same default at the client.
|
|
defaultHtlcConfTarget = loop.DefaultHtlcConfTarget
|
|
)
|
|
|
|
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)
|
|
|
|
// defaultBudgetRefreshPeriod is the default amount of time we wait for
|
|
// the autoloop budget to be refreshed.
|
|
defaultBudgetRefreshPeriod = time.Hour * 24 * 7
|
|
|
|
// 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")
|
|
|
|
// ErrAmbiguousDestAddr is returned when a destination address and
|
|
// a extended public key account is set.
|
|
ErrAmbiguousDestAddr = errors.New("ambiguous destination address")
|
|
|
|
// ErrAccountAndAddrType indicates if an account is set but the
|
|
// account address type is not or vice versa.
|
|
ErrAccountAndAddrType = errors.New("account and address type have " +
|
|
"to be both either set or unset")
|
|
|
|
// ErrStaticAddressAutoloopExperimental is returned when static-address
|
|
// autoloop is configured without the daemon experimental flag.
|
|
ErrStaticAddressAutoloopExperimental = errors.New("static address " +
|
|
"autoloop requires --experimental")
|
|
)
|
|
|
|
// 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,
|
|
initiator string) (*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(context.Context) ([]*loopdb.LoopOut, error)
|
|
|
|
// GetLoopOut returns a single loop out swap based on the provided swap
|
|
// hash.
|
|
GetLoopOut func(ctx context.Context, hash lntypes.Hash) (*loopdb.LoopOut, error)
|
|
|
|
// ListLoopIn returns all of the loop in swaps stored on disk.
|
|
ListLoopIn func(ctx context.Context) ([]*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)
|
|
|
|
// LoopInQuote provides a quote for a loop in swap.
|
|
LoopInQuote func(ctx context.Context,
|
|
request *loop.LoopInQuoteRequest) (*loop.LoopInQuote, error)
|
|
|
|
// LoopOut dispatches a loop out.
|
|
LoopOut func(ctx context.Context, request *loop.OutRequest) (
|
|
*loop.LoopOutSwapInfo, error)
|
|
|
|
// LoopIn dispatches a loop in swap.
|
|
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)
|
|
|
|
// EnableStaticAddressAutoloop allows the liquidity manager to accept
|
|
// static-address loop-in sources for new autoloop parameters.
|
|
EnableStaticAddressAutoloop bool
|
|
|
|
// LoopInTerms returns the terms for a loop in swap.
|
|
LoopInTerms func(ctx context.Context,
|
|
initiator string) (*loop.LoopInTerms, error)
|
|
|
|
// LoopOutTerms returns the terms for a loop out swap.
|
|
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,
|
|
maxPaymentAmt btcutil.Amount) (btcutil.Amount, 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
|
|
|
|
// PutLiquidityParams writes the serialized `Parameters` into db.
|
|
//
|
|
// NOTE: the params are encoded using `proto.Marshal` over an RPC
|
|
// request.
|
|
PutLiquidityParams func(ctx context.Context, params []byte) error
|
|
|
|
// FetchLiquidityParams reads the serialized `Parameters` from db.
|
|
//
|
|
// NOTE: the params are decoded using `proto.Unmarshal` over a
|
|
// serialized RPC request.
|
|
FetchLiquidityParams func(ctx context.Context) ([]byte, error)
|
|
}
|
|
|
|
// 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
|
|
|
|
// activeStickyLoops is a counter that helps us keep track of currently
|
|
// active sticky loops. We use this to ensure we don't dispatch more
|
|
// than the max configured loops at a time.
|
|
activeStickyLoops int
|
|
|
|
// activeStickyLock is a lock to ensure atomic access to the
|
|
// activeStickyLoops counter.
|
|
activeStickyLock 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, instead
|
|
// of starting and stopping the ticker whenever our parameters are updated.
|
|
func (m *Manager) Run(ctx context.Context) error {
|
|
m.cfg.AutoloopTicker.Resume()
|
|
defer m.cfg.AutoloopTicker.Stop()
|
|
|
|
// Before we start the main loop, load the params from db.
|
|
req, err := m.loadParams(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set the params if there's one.
|
|
if req != nil {
|
|
if err := m.SetParameters(ctx, req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for {
|
|
select {
|
|
case <-m.cfg.AutoloopTicker.Ticks():
|
|
if m.params.EasyAutoloop {
|
|
err := m.easyAutoLoop(ctx)
|
|
if err != nil {
|
|
log.Errorf("easy autoloop failed: %v",
|
|
err)
|
|
}
|
|
} else {
|
|
err := m.autoloop(ctx)
|
|
switch err {
|
|
case ErrNoRules:
|
|
log.Debugf("no rules configured for " +
|
|
"autoloop")
|
|
|
|
case nil:
|
|
|
|
default:
|
|
log.Errorf("autoloop failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Try to automatically dispach an asset auto-loop.
|
|
for assetID := range m.params.AssetAutoloopParams {
|
|
err = m.easyAssetAutoloop(ctx, assetID)
|
|
if err != nil {
|
|
log.Errorf("easy asset autoloop "+
|
|
"failed: id: %v, err: %v",
|
|
assetID, 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 takes an RPC request and calls the internal method to set
|
|
// parameters for the manager.
|
|
func (m *Manager) SetParameters(ctx context.Context,
|
|
req *clientrpc.LiquidityParameters) error {
|
|
|
|
params, err := RpcToParameters(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := m.setParameters(ctx, *params); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Save the params on disk.
|
|
//
|
|
// NOTE: alternatively we can save the bytes in memory and persist them
|
|
// on disk during shutdown to save us some IO cost from hitting the db.
|
|
// Since setting params is NOT a frequent action, it's should put
|
|
// little pressure on our db. Only when performance becomes an issue,
|
|
// we can then apply the alternative.
|
|
return m.saveParams(ctx, req)
|
|
}
|
|
|
|
// setParameters updates our current set of parameters if the new parameters
|
|
// provided are valid.
|
|
func (m *Manager) setParameters(ctx context.Context,
|
|
params Parameters) error {
|
|
|
|
// Static-address autoloop is still experimental. Check this before
|
|
// any network-dependent validation so persisted params cannot start
|
|
// the feature after a restart without the daemon opt-in.
|
|
if params.LoopInSource == LoopInSourceStaticAddress &&
|
|
!m.cfg.EnableStaticAddressAutoloop {
|
|
|
|
return ErrStaticAddressAutoloopExperimental
|
|
}
|
|
|
|
restrictions, err := m.cfg.Restrictions(
|
|
ctx, swap.TypeOut, getInitiator(m.params),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
|
|
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
|
|
}
|
|
|
|
// saveParams marshals an RPC request and saves it to db.
|
|
func (m *Manager) saveParams(ctx context.Context, req proto.Message) error {
|
|
// Marshal the params.
|
|
paramsBytes, err := proto.Marshal(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Save the params on disk.
|
|
if err := m.cfg.PutLiquidityParams(ctx, paramsBytes); err != nil {
|
|
return fmt.Errorf("failed to save params: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadParams unmarshals a serialized RPC request from db and returns the RPC
|
|
// request.
|
|
func (m *Manager) loadParams(ctx context.Context) (
|
|
*clientrpc.LiquidityParameters, error) {
|
|
|
|
paramsBytes, err := m.cfg.FetchLiquidityParams(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read params: %v", err)
|
|
}
|
|
|
|
// Return early if there's nothing saved.
|
|
if paramsBytes == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Unmarshal the params.
|
|
req := &clientrpc.LiquidityParameters{}
|
|
err = proto.Unmarshal(paramsBytes, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal params: %v", err)
|
|
}
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// 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 {
|
|
// First check if we should refresh our budget before calculating any
|
|
// swaps for autoloop.
|
|
m.refreshAutoloopBudget(ctx)
|
|
|
|
suggestion, err := m.SuggestSwaps(ctx)
|
|
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 out: %v sats over "+
|
|
"%v", swap.Amount, swap.OutgoingChanSet)
|
|
|
|
continue
|
|
}
|
|
|
|
// Check if the parameter for custom address is defined for loop
|
|
// outs.
|
|
if m.params.DestAddr != nil {
|
|
swap.DestAddr = m.params.DestAddr
|
|
swap.IsExternalAddr = true
|
|
}
|
|
|
|
go m.dispatchStickyLoopOut(
|
|
ctx, swap, defaultAmountBackoffRetry,
|
|
defaultAmountBackoff,
|
|
)
|
|
}
|
|
|
|
for _, in := range suggestion.InSwaps {
|
|
// If we don't actually have dispatch of swaps enabled, log
|
|
// suggestions.
|
|
if !m.params.Autoloop {
|
|
log.Debugf("recommended autoloop in: %v sats over "+
|
|
"%v", in.Amount, in.LastHop)
|
|
|
|
continue
|
|
}
|
|
|
|
loopIn, err := m.cfg.LoopIn(ctx, &in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Infof("loop in automatically dispatched: hash: %v, "+
|
|
"address: p2wsh(%v), p2tr(%v)", loopIn.SwapHash,
|
|
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
|
|
}
|
|
|
|
// easyAutoLoop is the main entry point for the easy auto loop functionality.
|
|
// This function will try to dispatch a swap in order to meet the easy autoloop
|
|
// requirements. For easyAutoloop to work there needs to be an
|
|
// EasyAutoloopTarget defined in the parameters. Easy autoloop also uses the
|
|
// configured max inflight swaps and budget rules defined in the parameters.
|
|
func (m *Manager) easyAutoLoop(ctx context.Context) error {
|
|
if !m.params.Autoloop {
|
|
return nil
|
|
}
|
|
|
|
// First check if we should refresh our budget before calculating any
|
|
// swaps for autoloop.
|
|
m.refreshAutoloopBudget(ctx)
|
|
|
|
// Dispatch the best easy autoloop swap.
|
|
err := m.dispatchBestEasyAutoloopSwap(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// easyAssetAutoloop is the main entry point for the easy auto loop functionality
|
|
// for assets. This function will try to dispatch a swap in order to meet the
|
|
// easy autoloop requirements for the given asset. For easyAutoloop to work
|
|
// there needs to be an EasyAutoloopTarget defined in the parameters. Easy
|
|
// autoloop also uses the configured max inflight swaps and budget rules defined
|
|
// in the parameters.
|
|
func (m *Manager) easyAssetAutoloop(ctx context.Context, assetID string) error {
|
|
if !m.params.Autoloop {
|
|
return nil
|
|
}
|
|
|
|
assetParams, ok := m.params.AssetAutoloopParams[assetID]
|
|
if !ok && !assetParams.EnableEasyOut {
|
|
return nil
|
|
}
|
|
|
|
// First check if we should refresh our budget before calculating any
|
|
// swaps for autoloop.
|
|
m.refreshAutoloopBudget(ctx)
|
|
|
|
// Dispatch the best easy autoloop swap.
|
|
targetAmt := assetParams.LocalTargetAssetAmount
|
|
|
|
return m.dispatchBestAssetEasyAutoloopSwap(ctx, assetID, targetAmt)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// dispatchBestEasyAutoloopSwap tries to dispatch a swap to bring the total
|
|
// local balance back to the target.
|
|
func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
|
|
// Retrieve existing swaps.
|
|
loopOut, err := m.cfg.ListLoopOut(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
loopIn, err := m.cfg.ListLoopIn(ctx)
|
|
if err != nil {
|
|
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.checkExistingAutoLoopsWithStatic(
|
|
loopOut, loopIn, staticLoopIns,
|
|
)
|
|
|
|
err = m.checkSummaryBudget(summary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = m.checkSummaryInflight(summary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get all channels in order to calculate current total local balance.
|
|
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
usableChannels := make([]lndclient.ChannelInfo, 0, len(channels))
|
|
localTotal := btcutil.Amount(0)
|
|
for _, channel := range channels {
|
|
if channelIsCustom(channel) {
|
|
continue
|
|
}
|
|
localTotal += channel.LocalBalance
|
|
usableChannels = append(usableChannels, channel)
|
|
}
|
|
|
|
// Since we're only autolooping-out we need to check if we are below
|
|
// the target, meaning that we already meet the requirements.
|
|
if localTotal <= m.params.EasyAutoloopTarget {
|
|
log.Debugf("total local balance %v below target %v",
|
|
localTotal, m.params.EasyAutoloopTarget)
|
|
return nil
|
|
}
|
|
|
|
restrictions, err := m.cfg.Restrictions(
|
|
ctx, swap.TypeOut, getInitiator(m.params),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Calculate the amount that we want to loop out. If it exceeds the max
|
|
// allowed clamp it to max.
|
|
amount := localTotal - m.params.EasyAutoloopTarget
|
|
amount = min(amount, restrictions.Maximum)
|
|
|
|
// If the amount we want to loop out is less than the minimum we can't
|
|
// proceed with a swap, so we return early.
|
|
if amount < restrictions.Minimum {
|
|
log.Debugf("easy autoloop: swap amount is below minimum swap "+
|
|
"size, minimum=%v, need to swap %v",
|
|
restrictions.Minimum, amount)
|
|
return nil
|
|
}
|
|
|
|
log.Debugf("easy autoloop: local_total=%v, target=%v, "+
|
|
"attempting to loop out %v", localTotal,
|
|
m.params.EasyAutoloopTarget, amount)
|
|
|
|
// Start building that swap.
|
|
builder := newLoopOutBuilder(m.cfg)
|
|
|
|
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")
|
|
}
|
|
|
|
log.Debugf("easy autoloop: picked channel %v with local balance %v",
|
|
channel.ChannelID, channel.LocalBalance)
|
|
|
|
swapAmt, err := btcutil.NewAmount(
|
|
math.Min(channel.LocalBalance.ToBTC(), amount.ToBTC()),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If no fee is set, override our current parameters in order to use the
|
|
// default percent limit of easy-autoloop.
|
|
easyParams := m.params
|
|
|
|
switch feeLimit := easyParams.FeeLimit.(type) {
|
|
case *FeePortion:
|
|
if feeLimit.PartsPerMillion == 0 {
|
|
easyParams.FeeLimit = &FeePortion{
|
|
PartsPerMillion: defaultFeePPM,
|
|
}
|
|
}
|
|
default:
|
|
easyParams.FeeLimit = &FeePortion{
|
|
PartsPerMillion: defaultFeePPM,
|
|
}
|
|
}
|
|
|
|
// Set the swap outgoing channel to the chosen channel.
|
|
outgoing := []lnwire.ShortChannelID{
|
|
lnwire.NewShortChanIDFromInt(channel.ChannelID),
|
|
}
|
|
|
|
suggestion, err := builder.buildSwap(
|
|
ctx, channel.PubKeyBytes, outgoing, swapAmt, easyParams,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var swp loop.OutRequest
|
|
if t, ok := suggestion.(*loopOutSwapSuggestion); ok {
|
|
swp = t.OutRequest
|
|
} else {
|
|
return fmt.Errorf("unexpected swap suggestion type: %T", t)
|
|
}
|
|
|
|
// Dispatch a sticky loop out.
|
|
go m.dispatchStickyLoopOut(
|
|
ctx, swp, defaultAmountBackoffRetry, defaultAmountBackoff,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// dispatchBestAssetEasyAutoloopSwap tries to dispatch a swap to bring the total
|
|
// local balance back to the target for the given asset.
|
|
func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
|
|
assetID string, localTarget uint64) error {
|
|
|
|
if len(assetID) != sha256.Size*2 {
|
|
return fmt.Errorf("invalid asset id: %v", assetID)
|
|
}
|
|
|
|
// Retrieve existing swaps.
|
|
loopOut, err := m.cfg.ListLoopOut(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
loopIn, err := m.cfg.ListLoopIn(ctx)
|
|
if err != nil {
|
|
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.checkExistingAutoLoopsWithStatic(
|
|
loopOut, loopIn, staticLoopIns,
|
|
)
|
|
|
|
err = m.checkSummaryBudget(summary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = m.checkSummaryInflight(summary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get all channels in order to calculate current total local balance.
|
|
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If we are running a custom asset, we'll need to get a random asset
|
|
// peer pubkey in order to rfq the asset price.
|
|
var assetPeerPubkey []byte
|
|
|
|
usableChannels := []lndclient.ChannelInfo{}
|
|
localTotal := uint64(0)
|
|
for _, channel := range channels {
|
|
// We are only interested in custom asset channels.
|
|
if !channelIsCustom(channel) {
|
|
continue
|
|
}
|
|
|
|
assetData := getCustomAssetData(channel, assetID)
|
|
if assetData == nil {
|
|
continue
|
|
}
|
|
|
|
// We'll overwrite the channel local balance to be
|
|
// the custom asset balance. This allows us to make
|
|
// use of existing logic.
|
|
channel.LocalBalance = btcutil.Amount(assetData.LocalBalance)
|
|
usableChannels = append(usableChannels, channel)
|
|
|
|
// We'll use a random peer pubkey in order to get a rfq for the asset
|
|
// to get a rough amount of sats to swap amount.
|
|
assetPeerPubkey = channel.PubKeyBytes[:]
|
|
|
|
localTotal += assetData.LocalBalance
|
|
}
|
|
|
|
// Since we're only autolooping-out we need to check if we are below
|
|
// the target, meaning that we already meet the requirements.
|
|
if localTotal <= localTarget {
|
|
log.Debugf("Asset: %v... total local balance %v below target %v",
|
|
assetID[:8], localTotal, localTarget)
|
|
return nil
|
|
}
|
|
|
|
restrictions, err := m.cfg.Restrictions(
|
|
ctx, swap.TypeOut, getInitiator(m.params),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Calculate the assetAmount that we want to loop out. If it exceeds the
|
|
// max allowed clamp it to max.
|
|
assetAmount := localTotal - localTarget
|
|
|
|
// We need a request sat amount for the asset price request. We'll use
|
|
// the average of the min and max restrictions.
|
|
assetPriceRequestSatAmt := (restrictions.Minimum + restrictions.Maximum) / 2
|
|
|
|
// If we run a custom asset, we'll need to convert the asset amount
|
|
// we want to swap to the satoshi amount.
|
|
satAmount, err := m.cfg.GetAssetPrice(
|
|
ctx, assetID, assetPeerPubkey, assetAmount,
|
|
assetPriceRequestSatAmt,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if satAmount > restrictions.Maximum {
|
|
log.Debugf("Asset %v easy autoloop: using maximum allowed "+
|
|
"swap amount, maximum=%v, need to swap %v",
|
|
assetID[:8], restrictions.Maximum, satAmount)
|
|
satAmount = restrictions.Maximum
|
|
}
|
|
|
|
// If the amount we want to loop out is less than the minimum we can't
|
|
// proceed with a swap, so we return early.
|
|
if satAmount < restrictions.Minimum {
|
|
log.Debugf("Asset %v easy autoloop: swap amount is below"+
|
|
" minimum swap size, minimum=%v, need to swap %v",
|
|
assetID[:8], restrictions.Minimum, satAmount)
|
|
return nil
|
|
}
|
|
|
|
satsPerAsset := float64(satAmount) / float64(assetAmount)
|
|
|
|
log.Debugf("Asset %v easy autoloop: local_total=%v, target=%v, "+
|
|
"attempting to loop out %v assets corresponding to %v sats",
|
|
assetID[:8], localTotal, localTarget, assetAmount, satAmount)
|
|
|
|
// Start building that swap.
|
|
builder := newLoopOutBuilder(m.cfg)
|
|
|
|
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")
|
|
}
|
|
|
|
log.Debugf("Asset %v easy autoloop: picked channel %v with local "+
|
|
"balance %v", assetID[:8], channel.ChannelID,
|
|
int(channel.LocalBalance))
|
|
|
|
// If no fee is set, override our current parameters in order to use the
|
|
// default percent limit of easy-autoloop.
|
|
easyParams := m.params
|
|
|
|
switch feeLimit := easyParams.FeeLimit.(type) {
|
|
case *FeePortion:
|
|
if feeLimit.PartsPerMillion == 0 {
|
|
easyParams.FeeLimit = &FeePortion{
|
|
PartsPerMillion: defaultFeePPM,
|
|
}
|
|
}
|
|
|
|
default:
|
|
easyParams.FeeLimit = &FeePortion{
|
|
PartsPerMillion: defaultFeePPM,
|
|
}
|
|
}
|
|
|
|
// Set the swap outgoing channel to the chosen channel.
|
|
outgoing := []lnwire.ShortChannelID{
|
|
lnwire.NewShortChanIDFromInt(channel.ChannelID),
|
|
}
|
|
|
|
assetSwap := &assetSwapInfo{
|
|
assetID: assetID,
|
|
peerPubkey: channel.PubKeyBytes[:],
|
|
}
|
|
|
|
suggestion, err := builder.buildSwap(
|
|
ctx, channel.PubKeyBytes, outgoing, satAmount, easyParams,
|
|
withAssetSwapInfo(assetSwap),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var swp loop.OutRequest
|
|
if t, ok := suggestion.(*loopOutSwapSuggestion); ok {
|
|
swp = t.OutRequest
|
|
} else {
|
|
return fmt.Errorf("unexpected swap suggestion type: %T", t)
|
|
}
|
|
|
|
// Dispatch a sticky loop out.
|
|
go m.dispatchStickyLoopOut(
|
|
ctx, swp, defaultAmountBackoffRetry, defaultAmountBackoff,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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
|
|
|
|
// DisqualifiedPeers 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 {
|
|
switch t := swap.(type) {
|
|
case *loopOutSwapSuggestion:
|
|
s.OutSwaps = append(s.OutSwaps, t.OutRequest)
|
|
|
|
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)
|
|
}
|
|
|
|
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).
|
|
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 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) {
|
|
|
|
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
|
|
}
|
|
|
|
// Get restrictions placed on swaps by the server.
|
|
outRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeOut)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeIn)
|
|
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(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
loopIn, err := m.cfg.ListLoopIn(ctx)
|
|
if err != nil {
|
|
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.checkExistingAutoLoopsWithStatic(
|
|
loopOut, loopIn, staticLoopIns,
|
|
)
|
|
|
|
err = m.checkSummaryBudget(summary)
|
|
if err != nil {
|
|
return m.singleReasonSuggestion(ReasonBudgetElapsed), nil
|
|
}
|
|
|
|
allowedSwaps, err := m.checkSummaryInflight(summary)
|
|
if err != nil {
|
|
return m.singleReasonSuggestion(ReasonInFlight), nil
|
|
}
|
|
|
|
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
|
|
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 {
|
|
if channelIsCustom(channel) {
|
|
continue
|
|
}
|
|
|
|
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.currentSwapTrafficWithStatic(
|
|
loopOut, loopIn, staticLoopIns,
|
|
)
|
|
|
|
var (
|
|
candidates []suggestionCandidate
|
|
resp = newSuggestions()
|
|
)
|
|
|
|
for peer, balances := range peerChannels {
|
|
rule, haveRule := m.params.PeerRules[peer]
|
|
if !haveRule {
|
|
continue
|
|
}
|
|
|
|
suggestion, err := m.suggestSwap(
|
|
ctx, traffic, balances, rule, outRestrictions,
|
|
inRestrictions,
|
|
)
|
|
var reasonErr *reasonError
|
|
if errors.As(err, &reasonErr) {
|
|
resp.DisqualifiedPeers[peer] = reasonErr.reason
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
candidates = append(candidates, suggestion)
|
|
}
|
|
|
|
for _, channel := range channels {
|
|
balance := newBalances(channel)
|
|
|
|
channelID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
|
|
|
|
if channelIsCustom(channel) {
|
|
resp.DisqualifiedChans[channelID] =
|
|
ReasonCustomChannelData
|
|
|
|
continue
|
|
}
|
|
|
|
rule, ok := m.params.ChannelRules[channelID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
suggestion, err := m.suggestSwap(
|
|
ctx, traffic, balance, rule, outRestrictions,
|
|
inRestrictions,
|
|
)
|
|
|
|
var reasonErr *reasonError
|
|
if errors.As(err, &reasonErr) {
|
|
resp.DisqualifiedChans[channelID] = reasonErr.reason
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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(candidates) == 0 {
|
|
return resp, nil
|
|
}
|
|
|
|
// Sort suggestions by amount in descending order.
|
|
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
|
|
// 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, candidate suggestionCandidate) {
|
|
for _, peer := range candidate.peers(channelPeers) {
|
|
_, ok := m.params.PeerRules[peer]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
resp.DisqualifiedPeers[peer] = reason
|
|
}
|
|
|
|
for _, channel := range candidate.channels() {
|
|
_, ok := m.params.ChannelRules[channel]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
resp.DisqualifiedChans[channel] = reason
|
|
}
|
|
}
|
|
|
|
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.
|
|
var reason Reason
|
|
switch {
|
|
case available == 0:
|
|
reason = ReasonBudgetInsufficient
|
|
|
|
case resp.count() == allowedSwaps:
|
|
reason = ReasonInFlight
|
|
}
|
|
|
|
if reason != ReasonNone {
|
|
setReason(reason, candidate)
|
|
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 {
|
|
refreshTime := m.params.AutoFeeRefreshPeriod -
|
|
time.Since(m.params.AutoloopBudgetLastRefresh)
|
|
|
|
log.Infof("Swap fee exceeds budget, remaining budget: "+
|
|
"%v, swap fee %v, next budget refresh: %v",
|
|
available, fees, refreshTime)
|
|
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) (suggestionCandidate, error) {
|
|
|
|
var (
|
|
builder swapBuilder
|
|
restrictions *Restrictions
|
|
)
|
|
|
|
// Get an appropriate builder and set of restrictions based on our swap
|
|
// type.
|
|
switch rule.Type {
|
|
case swap.TypeOut:
|
|
builder = newLoopOutBuilder(m.cfg)
|
|
restrictions = outRestrictions
|
|
|
|
case swap.TypeIn:
|
|
builder = newLoopInBuilder(m.cfg)
|
|
restrictions = inRestrictions
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unsupported swap type: %v", rule.Type)
|
|
}
|
|
|
|
// 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.
|
|
if err := builder.maySwap(ctx, m.params); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First, check whether this peer/channel combination is already in use
|
|
// for our swap.
|
|
err := builder.inUse(traffic, balance.pubkey, balance.channels)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Next, get the amount that we need to swap for this entity, skipping
|
|
// over it if no change in liquidity is required.
|
|
amount := rule.swapAmount(balance, restrictions, rule.Type)
|
|
if amount == 0 {
|
|
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,
|
|
)
|
|
}
|
|
|
|
// 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, getInitiator(m.params),
|
|
)
|
|
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
|
|
}
|
|
|
|
// 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 btcutil.Amount) btcutil.Amount {
|
|
|
|
return prepayRouting + minerFee + swapFee + swapRouting
|
|
}
|
|
|
|
// 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, 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, 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
|
|
|
|
for _, out := range loopOuts {
|
|
if !isAutoloopLabel(out.Contract.Label) {
|
|
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++
|
|
|
|
summary.pendingFees += worstCaseOutFees(
|
|
out.Contract.MaxPrepayRoutingFee,
|
|
out.Contract.MaxSwapRoutingFee,
|
|
out.Contract.MaxSwapFee,
|
|
out.Contract.MaxMinerFee,
|
|
)
|
|
} else if out.LastUpdateTime().After(
|
|
m.params.AutoloopBudgetLastRefresh,
|
|
) {
|
|
|
|
summary.spentFees += out.State().Cost.Total()
|
|
}
|
|
}
|
|
|
|
for _, in := range loopIns {
|
|
if !isAutoloopLabel(in.Contract.Label) {
|
|
continue
|
|
}
|
|
|
|
pending := in.State().State.Type() == loopdb.StateTypePending
|
|
inBudget := !in.LastUpdateTime().
|
|
Before(m.params.AutoloopBudgetLastRefresh)
|
|
|
|
// If an autoloop is in a pending state, we always count it in
|
|
// our current budget, and record the worst-case fees for it,
|
|
// because we do not know how it will resolve.
|
|
if pending {
|
|
summary.inFlightCount++
|
|
summary.pendingFees += worstCaseInFees(
|
|
in.Contract.MaxMinerFee, in.Contract.MaxSwapFee,
|
|
defaultLoopInSweepFee,
|
|
)
|
|
} else if inBudget {
|
|
summary.spentFees += in.State().Cost.Total()
|
|
}
|
|
}
|
|
|
|
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(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()
|
|
|
|
// 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 over swaps that may come through any peer.
|
|
if in.Contract.LastHop == nil {
|
|
continue
|
|
}
|
|
|
|
pubkey := *in.Contract.LastHop
|
|
|
|
switch {
|
|
// Include any pending swaps in our ongoing set of swaps. Swaps
|
|
// that reached InvoiceSettled are not considered ongoing since
|
|
// from the client's perspective the swap is complete. This
|
|
// consideration allows the client to dispatch the next autoloop
|
|
// in once an invoice for a previous swap is settled.
|
|
case in.State().State.Type() == loopdb.StateTypePending &&
|
|
in.State().State != loopdb.StateInvoiceSettled:
|
|
|
|
traffic.ongoingLoopIn[pubkey] = true
|
|
|
|
// If a swap failed with an on-chain timeout, the server could
|
|
// not route to us. We add it to our backoff list so that
|
|
// there's some time for routing conditions to improve.
|
|
case in.State().State == loopdb.StateFailTimeout:
|
|
failedAt := in.LastUpdate().Time
|
|
|
|
if failedAt.After(failureCutoff) {
|
|
traffic.failedLoopIn[pubkey] = failedAt
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// refreshAutoloopBudget checks whether the elapsed time since our last autoloop
|
|
// budget refresh is greater than our configured refresh period. If so, the last
|
|
// refresh timestamp.
|
|
func (m *Manager) refreshAutoloopBudget(ctx context.Context) {
|
|
if time.Since(m.params.AutoloopBudgetLastRefresh) >
|
|
m.params.AutoFeeRefreshPeriod {
|
|
|
|
log.Debug("Refreshing autoloop budget")
|
|
m.params.AutoloopBudgetLastRefresh = m.cfg.Clock.Now()
|
|
|
|
paramsRpc, err := ParametersToRpc(m.params)
|
|
if err != nil {
|
|
log.Errorf("Error converting parameters to rpc: %v",
|
|
err)
|
|
return
|
|
}
|
|
|
|
err = m.saveParams(ctx, paramsRpc)
|
|
if err != nil {
|
|
log.Errorf("Error saving parameters: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatchStickyLoopOut attempts to dispatch a loop out swap that will
|
|
// automatically retry its execution with an amount based backoff.
|
|
func (m *Manager) dispatchStickyLoopOut(ctx context.Context,
|
|
out loop.OutRequest, retryCount uint16, amountBackoff float64) {
|
|
|
|
// Check our sticky loop counter to decide whether we should continue
|
|
// executing this loop.
|
|
m.activeStickyLock.Lock()
|
|
if m.activeStickyLoops >= m.params.MaxAutoInFlight {
|
|
m.activeStickyLock.Unlock()
|
|
return
|
|
}
|
|
|
|
m.activeStickyLoops += 1
|
|
m.activeStickyLock.Unlock()
|
|
|
|
// No matter the outcome, decrease the counter upon exiting sticky loop.
|
|
defer func() {
|
|
m.activeStickyLock.Lock()
|
|
m.activeStickyLoops -= 1
|
|
m.activeStickyLock.Unlock()
|
|
}()
|
|
|
|
for range int(retryCount) {
|
|
// Dispatch the swap.
|
|
swap, err := m.cfg.LoopOut(ctx, &out)
|
|
if err != nil {
|
|
log.Errorf("unable to dispatch loop out, amt: %v, "+
|
|
"err: %v", out.Amount, err)
|
|
return
|
|
}
|
|
|
|
log.Infof("loop out automatically dispatched: hash: %v, "+
|
|
"address: %v, amount %v", swap.SwapHash,
|
|
swap.HtlcAddress, out.Amount)
|
|
|
|
updates := make(chan *loopdb.SwapState, 1)
|
|
|
|
// Monitor the swap state and write the desired update to the
|
|
// update channel. We do not want to read all of the swap state
|
|
// updates, just the one that will help us assume the state of
|
|
// the off-chain payment.
|
|
go m.waitForSwapPayment(
|
|
ctx, swap.SwapHash, updates, defaultSwapWaitTimeout,
|
|
)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case update := <-updates:
|
|
if update == nil {
|
|
// If update is nil then no update occurred
|
|
// within the defined timeout period. It's
|
|
// better to return and not attempt a retry.
|
|
log.Debug(
|
|
"No payment update received for swap "+
|
|
"%v, skipping amount backoff",
|
|
swap.SwapHash,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if *update == loopdb.StateFailOffchainPayments {
|
|
// Save the old amount so we can log it.
|
|
oldAmt := out.Amount
|
|
|
|
// If we failed to pay the server, we will
|
|
// decrease the amount of the swap and try
|
|
// again.
|
|
out.Amount -= btcutil.Amount(
|
|
float64(out.Amount) * amountBackoff,
|
|
)
|
|
|
|
log.Infof("swap %v: amount backoff old amount="+
|
|
"%v, new amount=%v", swap.SwapHash,
|
|
oldAmt, out.Amount)
|
|
|
|
continue
|
|
} else {
|
|
// If the update channel did not return an
|
|
// off-chain payment failure we won't retry.
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForSwapPayment waits for a swap to progress beyond the stage of
|
|
// forwarding the payment to the server through the network. It returns the
|
|
// final update on the outcome through a channel.
|
|
func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash,
|
|
updateChan chan *loopdb.SwapState, timeout time.Duration) {
|
|
|
|
startTime := time.Now()
|
|
var (
|
|
swap *loopdb.LoopOut
|
|
err error
|
|
interval time.Duration
|
|
)
|
|
|
|
if m.params.CustomPaymentCheckInterval != 0 {
|
|
interval = m.params.CustomPaymentCheckInterval
|
|
} else {
|
|
interval = defaultPaymentCheckInterval
|
|
}
|
|
|
|
for time.Since(startTime) < timeout {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(interval):
|
|
}
|
|
|
|
swap, err = m.cfg.GetLoopOut(ctx, swapHash)
|
|
if err != nil {
|
|
log.Errorf(
|
|
"Error getting swap with hash %x: %v", swapHash,
|
|
err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
// If no update has occurred yet, continue in order to wait.
|
|
update := swap.LastUpdate()
|
|
if update == nil {
|
|
continue
|
|
}
|
|
|
|
// Write the update if the swap has reached a state the helps
|
|
// us determine whether the off-chain payment successfully
|
|
// reached the destination.
|
|
switch update.State {
|
|
case loopdb.StateFailInsufficientValue:
|
|
fallthrough
|
|
case loopdb.StateSuccess:
|
|
fallthrough
|
|
case loopdb.StateFailSweepTimeout:
|
|
fallthrough
|
|
case loopdb.StateFailTimeout:
|
|
fallthrough
|
|
case loopdb.StatePreimageRevealed:
|
|
fallthrough
|
|
case loopdb.StateFailOffchainPayments:
|
|
updateChan <- &update.State
|
|
return
|
|
}
|
|
}
|
|
|
|
// If no update occurred within the defined timeout we return an empty
|
|
// update to the channel, causing the sticky loop out to not retry
|
|
// anymore.
|
|
updateChan <- nil
|
|
}
|
|
|
|
// pickEasyAutoloopChannel picks a channel to be used for an easy autoloop swap.
|
|
// 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(ctx context.Context,
|
|
channels []lndclient.ChannelInfo, restrictions *Restrictions,
|
|
loopOut []*loopdb.LoopOut, loopIn []*loopdb.LoopIn,
|
|
satsPerAsset float64) (*lndclient.ChannelInfo, error) {
|
|
|
|
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
|
|
// balance.
|
|
sort.Slice(channels, func(i, j int) bool {
|
|
return channels[i].LocalBalance > channels[j].LocalBalance
|
|
})
|
|
|
|
// Build a set of excluded peers for a quick lookup.
|
|
excluded := make(
|
|
map[route.Vertex]struct{},
|
|
len(m.params.EasyAutoloopExcludedPeers),
|
|
)
|
|
for _, v := range m.params.EasyAutoloopExcludedPeers {
|
|
excluded[v] = struct{}{}
|
|
}
|
|
|
|
// Check each channel, since channels are already sorted, we return the
|
|
// first channel that passes all checks.
|
|
for _, channel := range channels {
|
|
// Skip channels whose remote peer is excluded for easy autoloop.
|
|
if _, ok := excluded[channel.PubKeyBytes]; ok {
|
|
log.Debugf("Channel %v cannot be used for easy "+
|
|
"autoloop: peer %v manually excluded",
|
|
channel.ChannelID, channel.PubKeyBytes)
|
|
|
|
continue
|
|
}
|
|
|
|
shortChanID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
|
|
|
|
if !channel.Active {
|
|
log.Debugf("Channel %v cannot be used for easy "+
|
|
"autoloop: inactive", channel.ChannelID)
|
|
continue
|
|
}
|
|
|
|
lastFail, recentFail := traffic.failedLoopOut[shortChanID]
|
|
if recentFail {
|
|
log.Debugf("Channel %v cannot be used for easy "+
|
|
"autoloop: last failed swap was at %v",
|
|
channel.ChannelID, lastFail)
|
|
continue
|
|
}
|
|
|
|
if traffic.ongoingLoopOut[shortChanID] {
|
|
log.Debugf("Channel %v cannot be used for easy "+
|
|
"autoloop: ongoing swap", channel.ChannelID)
|
|
continue
|
|
}
|
|
|
|
localBalance := channel.LocalBalance
|
|
|
|
// If we're running a custom asset, the local balance is
|
|
// denominated in the asset's unit, so we convert it to
|
|
// back to sats to check the minimum.
|
|
if channelIsCustom(channel) {
|
|
localBalance = localBalance.MulF64(satsPerAsset)
|
|
}
|
|
|
|
if localBalance < restrictions.Minimum {
|
|
log.Debugf("Channel %v cannot be used for easy "+
|
|
"autoloop: insufficient local balance %v,"+
|
|
"minimum is %v, skipping remaining channels",
|
|
channel.ChannelID, channel.LocalBalance,
|
|
restrictions.Minimum)
|
|
return nil, nil
|
|
}
|
|
|
|
return &channel, nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *Manager) numActiveStickyLoops() int {
|
|
m.activeStickyLock.Lock()
|
|
defer m.activeStickyLock.Unlock()
|
|
|
|
return m.activeStickyLoops
|
|
}
|
|
|
|
func (m *Manager) checkSummaryBudget(summary *existingAutoLoopSummary) error {
|
|
if summary.totalFees() >= m.params.AutoFeeBudget {
|
|
return fmt.Errorf("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 nil
|
|
}
|
|
|
|
func (m *Manager) checkSummaryInflight(
|
|
summary *existingAutoLoopSummary) (int, error) {
|
|
// If we have already reached our total allowed number of in flight
|
|
// swaps we return early.
|
|
allowedSwaps := m.params.MaxAutoInFlight - summary.inFlightCount
|
|
if allowedSwaps <= 0 {
|
|
return 0, fmt.Errorf("%v autoloops allowed, %v in flight",
|
|
m.params.MaxAutoInFlight, summary.inFlightCount)
|
|
}
|
|
|
|
return allowedSwaps, nil
|
|
}
|
|
|
|
func getInitiator(params Parameters) string {
|
|
if params.EasyAutoloop {
|
|
return "easy-autoloop"
|
|
}
|
|
|
|
return "autoloop"
|
|
}
|
|
|
|
// isAutoloopLabel is a helper function that returns a flag indicating whether
|
|
// the provided label corresponds to an autoloop swap.
|
|
func isAutoloopLabel(label string) bool {
|
|
switch label {
|
|
case labels.AutoloopLabel(swap.TypeOut):
|
|
return true
|
|
|
|
case labels.AutoloopLabel(swap.TypeIn):
|
|
return true
|
|
|
|
case labels.EasyAutoloopLabel(swap.TypeOut):
|
|
return true
|
|
|
|
case labels.EasyAutoloopLabel(swap.TypeIn):
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// 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
|
|
failedLoopIn map[route.Vertex]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),
|
|
failedLoopIn: make(map[route.Vertex]time.Time),
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// channelIsCustom returns true if the channel has custom channel data.
|
|
// we'll want to ignore these channels for autoloop recommendations.
|
|
func channelIsCustom(channel lndclient.ChannelInfo) bool {
|
|
// If the channel has custom channel data, the channel is a
|
|
// non-standard channel, such as an asset channel and we
|
|
// don't want to consider it for swaps.
|
|
return channel.CustomChannelData != nil
|
|
}
|
|
|
|
// getCustomAssetData returns the asset data for a custom channel.
|
|
func getCustomAssetData(channel lndclient.ChannelInfo,
|
|
assetID string) *rfqmsg.JsonAssetChannel {
|
|
|
|
if channel.CustomChannelData == nil {
|
|
return nil
|
|
}
|
|
|
|
var assetData rfqmsg.JsonAssetChannel
|
|
err := json.Unmarshal(channel.CustomChannelData, &assetData)
|
|
if err != nil {
|
|
log.Errorf("Error unmarshalling custom channel %v data: %v",
|
|
channel.ChannelID, err)
|
|
return nil
|
|
}
|
|
|
|
for _, asset := range assetData.FundingAssets {
|
|
if asset.AssetGenesis.AssetID == assetID {
|
|
return &assetData
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|