loop/liquidity/parameters.go
Boris Nagaev ac58336104
looprpc: add autoloop loop-in source
Add a dedicated loop-in source enum to the liquidity parameters
rpc and wire it through the internal parameter model and CLI.

This keeps the source selection explicit before any static
autoloop planning lands, so operators can choose between the
legacy wallet-funded path and a future static-address-backed
path without relying on implicit fallback behavior.
2026-05-22 02:20:36 -05:00

743 lines
21 KiB
Go

package liquidity
import (
"errors"
"fmt"
"strings"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
clientrpc "github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/lnrpc/walletrpc"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
var (
// defaultParameters contains the default parameters that we start our
// liquidity manager with.
defaultParameters = Parameters{
AutoFeeBudget: defaultBudget,
AutoFeeRefreshPeriod: defaultBudgetRefreshPeriod,
AutoloopBudgetLastRefresh: time.Now(),
DestAddr: nil,
MaxAutoInFlight: defaultMaxInFlight,
ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule),
PeerRules: make(map[route.Vertex]*SwapRule),
FailureBackOff: defaultFailureBackoff,
SweepConfTarget: defaultConfTarget,
HtlcConfTarget: defaultHtlcConfTarget,
FeeLimit: defaultFeePortion(),
FastSwapPublication: true,
LoopInSource: LoopInSourceWallet,
}
)
const InfiniteDuration = (24 * 31 * 12 * 100) * time.Hour
// 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
// DestAddr is the address to be used for sweeping the on-chain HTLC
// that is related with a loop out.
DestAddr btcutil.Address
// An alternative destination address source for the swap. This field
// represents the name of the account in the backing lnd instance.
// Refer to lnd's wallet import functions for reference.
Account string
// The address type of the account specified in the account field.
AccountAddrType walletrpc.AddressType
// 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 refreshed.
AutoFeeBudget btcutil.Amount
// AutoFeeRefreshPeriod is the amount of time that must pass before the
// auto fee budget is refreshed.
AutoFeeRefreshPeriod time.Duration
// AutoloopBudgetLastRefresh is the last time at which we refreshed
// our budget.
AutoloopBudgetLastRefresh 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
// HtlcConfTarget is the confirmation target that we use for publishing
// loop in swap htlcs on chain.
HtlcConfTarget 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]*SwapRule
// 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]*SwapRule
// CustomPaymentCheckInterval is an optional custom interval to use when
// checking an autoloop loop out payments' payment status.
CustomPaymentCheckInterval time.Duration
// EasyAutoloop is a boolean that indicates whether we should use the
// easy autoloop feature.
EasyAutoloop bool
// EasyAutoloopTarget is the target amount of liquidity that we want to
// maintain in our channels.
EasyAutoloopTarget btcutil.Amount
// EasyAutoloopExcludedPeers is an optional list of peers that should be
// excluded from being selected for easy autoloop swaps.
EasyAutoloopExcludedPeers []route.Vertex
// AssetAutoloopParams maps an asset id hex encoded string to its
// easy autoloop parameters.
AssetAutoloopParams map[string]AssetParams
// FastSwapPublication controls publication deadline for new loop out
// swaps. If set to true, the deadline is set to immediate publication.
// If set to false, the deadline is set to 30 minutes.
FastSwapPublication bool
// LoopInSource controls which funding source autoloop uses for loop-in
// rules.
LoopInSource LoopInSource
}
// AssetParams define the asset specific autoloop parameters.
type AssetParams struct {
// EnableEasyOut is a boolean that indicates whether we should use the
// easy autoloop feature for this asset.
EnableEasyOut bool
// LocalTargetAssetAmount is the target amount of liquidity that we
// want to maintain in our channels.
LocalTargetAssetAmount uint64
}
// 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, htlc conf target: %v,fees: %v, "+
"auto budget: %v, budget refresh: %v, max auto in flight: %v, "+
"minimum swap size: %v, maximum swap size: %v, "+
"loop in source: %v",
strings.Join(ruleList, ","), p.FailureBackOff,
p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit,
p.AutoFeeBudget, p.AutoFeeRefreshPeriod, p.MaxAutoInFlight,
p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum,
p.LoopInSource)
}
// 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 rule.Type == swap.TypeIn {
return errors.New("channel level rules not supported for " +
"loop in swaps, only peer-level rules allowed")
}
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 p.HtlcConfTarget < 1 {
return fmt.Errorf("htlc confirmation target must be > 0")
}
if err := p.FeeLimit.validate(); err != nil {
return err
}
if p.AutoFeeBudget < 0 {
return ErrNegativeBudget
}
if p.MaxAutoInFlight <= 0 {
return ErrZeroInFlight
}
switch p.LoopInSource {
case LoopInSourceWallet, LoopInSourceStaticAddress:
default:
return fmt.Errorf("unknown loop in source: %v", p.LoopInSource)
}
// Destination address and account cannot be set at the same time.
if p.DestAddr != nil && len(p.DestAddr.String()) > 0 &&
len(p.Account) > 0 {
return ErrAmbiguousDestAddr
}
// If an account is specified the respective address type must be
// specified as well, or both must be unset.
if len(p.Account) == 0 !=
(p.AccountAddrType == walletrpc.AddressType_UNKNOWN) {
return ErrAccountAndAddrType
}
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
}
// 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]*SwapRule,
len(params.ChannelRules),
)
for channel, rule := range params.ChannelRules {
ruleCopy := *rule
paramCopy.ChannelRules[channel] = &ruleCopy
}
paramCopy.PeerRules = make(
map[route.Vertex]*SwapRule,
len(params.PeerRules),
)
for peer, rule := range params.PeerRules {
ruleCopy := *rule
paramCopy.PeerRules[peer] = &ruleCopy
}
return paramCopy
}
// rpcToFee converts the values provided over rpc to a fee limit interface,
// failing if an inconsistent set of fields are set.
func rpcToFee(req *clientrpc.LiquidityParameters) (FeeLimit, error) {
// Check which fee limit type we have values set for. If any fields
// relevant to our individual categories are set, we count that type
// as set.
isFeePPM := req.FeePpm != 0
isCategories := req.MaxSwapFeePpm != 0 || req.MaxRoutingFeePpm != 0 ||
req.MaxPrepayRoutingFeePpm != 0 || req.MaxMinerFeeSat != 0 ||
req.MaxPrepaySat != 0 || req.SweepFeeRateSatPerVbyte != 0
switch {
case isFeePPM && isCategories:
return nil, errors.New("set either fee ppm, or individual " +
"fee categories")
case isFeePPM:
return NewFeePortion(req.FeePpm), nil
case isCategories:
satPerKVbyte := chainfee.SatPerKVByte(
req.SweepFeeRateSatPerVbyte * 1000,
)
return NewFeeCategoryLimit(
req.MaxSwapFeePpm,
req.MaxRoutingFeePpm,
req.MaxPrepayRoutingFeePpm,
btcutil.Amount(req.MaxMinerFeeSat),
btcutil.Amount(req.MaxPrepaySat),
satPerKVbyte.FeePerKWeight(),
), nil
default:
return nil, errors.New("no fee categories set")
}
}
// rpcToRule switches on rpc rule type to convert to our rule interface.
func rpcToRule(rule *clientrpc.LiquidityRule) (*SwapRule, error) {
swapType := swap.TypeOut
if rule.SwapType == clientrpc.SwapType_LOOP_IN {
swapType = swap.TypeIn
}
switch rule.Type {
case clientrpc.LiquidityRuleType_UNKNOWN:
return nil, fmt.Errorf("rule type field must be set")
case clientrpc.LiquidityRuleType_THRESHOLD:
return &SwapRule{
ThresholdRule: NewThresholdRule(
int(rule.IncomingThreshold),
int(rule.OutgoingThreshold),
),
Type: swapType,
}, nil
default:
return nil, fmt.Errorf("unknown rule: %T", rule)
}
}
// rpcToLoopInSource converts the rpc loop-in source enum to the internal
// liquidity enum.
func rpcToLoopInSource(source clientrpc.LoopInSource) (LoopInSource, error) {
switch source {
case clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET:
return LoopInSourceWallet, nil
case clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS:
return LoopInSourceStaticAddress, nil
default:
return 0, fmt.Errorf("unknown rpc loop in source: %v", source)
}
}
// loopInSourceToRPC converts the internal loop-in source enum to its rpc
// representation.
func loopInSourceToRPC(source LoopInSource) (clientrpc.LoopInSource, error) {
switch source {
case LoopInSourceWallet:
return clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET, nil
case LoopInSourceStaticAddress:
return clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
nil
default:
return 0, fmt.Errorf("unknown loop in source: %v", source)
}
}
// RpcToParameters takes a `LiquidityParameters` and creates a `Parameters`
// from it.
func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters,
error) {
feeLimit, err := rpcToFee(req)
if err != nil {
return nil, err
}
var destaddr btcutil.Address
if len(req.AutoloopDestAddress) != 0 {
if req.AutoloopDestAddress == "default" {
destaddr = nil
} else {
destaddr, err = btcutil.DecodeAddress(
req.AutoloopDestAddress, nil,
)
if err != nil {
return nil, err
}
}
}
addrType := walletrpc.AddressType_UNKNOWN
if req.AccountAddrType == clientrpc.AddressType_TAPROOT_PUBKEY {
addrType = walletrpc.AddressType_TAPROOT_PUBKEY
}
easyAssetParams := make(map[string]AssetParams)
for asset, params := range req.EasyAssetParams {
easyAssetParams[asset] = AssetParams{
EnableEasyOut: params.Enabled,
LocalTargetAssetAmount: params.LocalTargetAssetAmt,
}
}
loopInSource, err := rpcToLoopInSource(req.LoopInSource)
if err != nil {
return nil, err
}
params := &Parameters{
FeeLimit: feeLimit,
SweepConfTarget: req.SweepConfTarget,
FailureBackOff: time.Duration(req.FailureBackoffSec) *
time.Second,
Autoloop: req.Autoloop,
AutoloopBudgetLastRefresh: time.Unix(
int64(req.AutoloopBudgetLastRefresh), 0,
),
DestAddr: destaddr,
Account: req.Account,
AccountAddrType: addrType,
AutoFeeBudget: btcutil.Amount(req.AutoloopBudgetSat),
MaxAutoInFlight: int(req.AutoMaxInFlight),
ChannelRules: make(
map[lnwire.ShortChannelID]*SwapRule,
),
PeerRules: make(
map[route.Vertex]*SwapRule,
),
ClientRestrictions: Restrictions{
Minimum: btcutil.Amount(req.MinSwapAmount),
Maximum: btcutil.Amount(req.MaxSwapAmount),
},
HtlcConfTarget: req.HtlcConfTarget,
EasyAutoloop: req.EasyAutoloop,
EasyAutoloopTarget: btcutil.Amount(
req.EasyAutoloopLocalTargetSat,
),
AssetAutoloopParams: easyAssetParams,
FastSwapPublication: req.FastSwapPublication,
LoopInSource: loopInSource,
}
if req.AutoloopBudgetRefreshPeriodSec != 0 {
params.AutoFeeRefreshPeriod =
time.Duration(req.AutoloopBudgetRefreshPeriodSec) *
time.Second
}
// Map excluded peers for easy autoloop, if any.
excludedPeersRPC := req.GetEasyAutoloopExcludedPeers()
params.EasyAutoloopExcludedPeers = make(
[]route.Vertex, 0, len(excludedPeersRPC),
)
for _, p := range excludedPeersRPC {
v, err := route.NewVertexFromBytes(p)
if err != nil {
return nil, err
}
params.EasyAutoloopExcludedPeers = append(
params.EasyAutoloopExcludedPeers, v,
)
}
// If an old-style budget was written to storage then express it by
// using the new auto budget parameters. If the newly added parameters
// have the 0 default value, but a budget was defined that means the
// client is using the old style budget parameters.
if req.AutoloopBudgetRefreshPeriodSec == 0 &&
req.AutoloopBudgetSat != 0 {
params.AutoFeeRefreshPeriod = InfiniteDuration
params.AutoloopBudgetLastRefresh = time.Unix(
int64(req.AutoloopBudgetStartSec), 0)
}
for _, rule := range req.Rules {
peerRule := rule.Pubkey != nil
chanRule := rule.ChannelId != 0
liquidityRule, err := rpcToRule(rule)
if err != nil {
return nil, err
}
switch {
case peerRule && chanRule:
return nil, fmt.Errorf("cannot set channel: %v and "+
"peer: %v fields in rule", rule.ChannelId,
rule.Pubkey)
case peerRule:
pubkey, err := route.NewVertexFromBytes(rule.Pubkey)
if err != nil {
return nil, err
}
if _, ok := params.PeerRules[pubkey]; ok {
return nil, fmt.Errorf("multiple rules set "+
"for peer: %v", pubkey)
}
params.PeerRules[pubkey] = liquidityRule
case chanRule:
shortID := lnwire.NewShortChanIDFromInt(rule.ChannelId)
if _, ok := params.ChannelRules[shortID]; ok {
return nil, fmt.Errorf("multiple rules set "+
"for channel: %v", shortID)
}
params.ChannelRules[shortID] = liquidityRule
default:
return nil, errors.New("please set channel id or " +
"pubkey for rule")
}
}
return params, nil
}
// ParametersToRpc takes a `Parameters` and creates a `LiquidityParameters`
// from it.
func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters,
error) {
totalRules := len(cfg.ChannelRules) + len(cfg.PeerRules)
var destaddr string
if cfg.DestAddr != nil {
destaddr = cfg.DestAddr.String()
}
var addrType clientrpc.AddressType
switch cfg.AccountAddrType {
case walletrpc.AddressType_TAPROOT_PUBKEY:
addrType = clientrpc.AddressType_TAPROOT_PUBKEY
default:
addrType = clientrpc.AddressType_ADDRESS_TYPE_UNKNOWN
}
easyAssetMap := make(
map[string]*clientrpc.EasyAssetAutoloopParams,
len(cfg.AssetAutoloopParams),
)
for asset, params := range cfg.AssetAutoloopParams {
easyAssetMap[asset] = &clientrpc.EasyAssetAutoloopParams{
Enabled: params.EnableEasyOut,
LocalTargetAssetAmt: params.LocalTargetAssetAmount,
}
}
loopInSource, err := loopInSourceToRPC(cfg.LoopInSource)
if err != nil {
return nil, err
}
rpcCfg := &clientrpc.LiquidityParameters{
SweepConfTarget: cfg.SweepConfTarget,
FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()),
Autoloop: cfg.Autoloop,
AutoloopBudgetSat: uint64(cfg.AutoFeeBudget),
AutoloopBudgetRefreshPeriodSec: uint64(
cfg.AutoFeeRefreshPeriod.Seconds(),
),
AutoloopBudgetLastRefresh: uint64(
cfg.AutoloopBudgetLastRefresh.Unix(),
),
AutoMaxInFlight: uint64(cfg.MaxAutoInFlight),
AutoloopDestAddress: destaddr,
Rules: make(
[]*clientrpc.LiquidityRule, 0, totalRules,
),
MinSwapAmount: uint64(
cfg.ClientRestrictions.Minimum,
),
MaxSwapAmount: uint64(
cfg.ClientRestrictions.Maximum,
),
HtlcConfTarget: cfg.HtlcConfTarget,
EasyAutoloop: cfg.EasyAutoloop,
EasyAutoloopLocalTargetSat: uint64(cfg.EasyAutoloopTarget),
Account: cfg.Account,
AccountAddrType: addrType,
EasyAssetParams: easyAssetMap,
FastSwapPublication: cfg.FastSwapPublication,
LoopInSource: loopInSource,
}
// Set excluded peers for easy autoloop.
rpcCfg.EasyAutoloopExcludedPeers = make(
[][]byte, 0, len(cfg.EasyAutoloopExcludedPeers),
)
for _, v := range cfg.EasyAutoloopExcludedPeers {
rpcCfg.EasyAutoloopExcludedPeers = append(
rpcCfg.EasyAutoloopExcludedPeers, v[:],
)
}
switch f := cfg.FeeLimit.(type) {
case *FeeCategoryLimit:
satPerByte := f.SweepFeeRateLimit.FeePerKVByte() / 1000
rpcCfg.SweepFeeRateSatPerVbyte = uint64(satPerByte)
rpcCfg.MaxMinerFeeSat = uint64(f.MaximumMinerFee)
rpcCfg.MaxSwapFeePpm = f.MaximumSwapFeePPM
rpcCfg.MaxRoutingFeePpm = f.MaximumRoutingFeePPM
rpcCfg.MaxPrepayRoutingFeePpm = f.MaximumPrepayRoutingFeePPM
rpcCfg.MaxPrepaySat = uint64(f.MaximumPrepay)
case *FeePortion:
rpcCfg.FeePpm = f.PartsPerMillion
default:
return nil, fmt.Errorf("unknown fee limit: %T", cfg.FeeLimit)
}
for channel, rule := range cfg.ChannelRules {
rpcRule := newRPCRule(channel.ToUint64(), nil, rule)
rpcCfg.Rules = append(rpcCfg.Rules, rpcRule)
}
for peer, rule := range cfg.PeerRules {
rpcRule := newRPCRule(0, peer[:], rule)
rpcCfg.Rules = append(rpcCfg.Rules, rpcRule)
}
return rpcCfg, nil
}
// newRPCRule is a helper function that creates a `LiquidityRule` based on the
// provided `SwapRule` for the given channelID or peer.
func newRPCRule(channelID uint64, peer []byte,
rule *SwapRule) *clientrpc.LiquidityRule {
rpcRule := &clientrpc.LiquidityRule{
ChannelId: channelID,
Pubkey: peer,
Type: clientrpc.LiquidityRuleType_THRESHOLD,
IncomingThreshold: uint32(rule.MinimumIncoming),
OutgoingThreshold: uint32(rule.MinimumOutgoing),
SwapType: clientrpc.SwapType_LOOP_OUT,
}
if rule.Type == swap.TypeIn {
rpcRule.SwapType = clientrpc.SwapType_LOOP_IN
}
return rpcRule
}