mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
liquidity: count static loop-ins
Teach the liquidity manager to include persisted static loop-ins in budget accounting, in-flight limits, and peer traffic backoff. This adds the static fee model used for conservative accounting and passes storage errors through the relevant planner helpers. The daemon wiring now exposes static loop-ins to liquidity so the manager can see the same ongoing swaps that the static-address subsystem persists, while easy autoloop keeps working with the new fallible traffic lookup path.
This commit is contained in:
parent
94fc04a71a
commit
7cf0a87c2b
10 changed files with 822 additions and 47 deletions
|
|
@ -47,10 +47,11 @@ func TestEasyAutoloopExcludedPeers(t *testing.T) {
|
|||
)
|
||||
|
||||
// Picking a channel should not pick the excluded peer's channel.
|
||||
picked := c.manager.pickEasyAutoloopChannel(
|
||||
[]lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions,
|
||||
nil, nil, 1,
|
||||
picked, err := c.manager.pickEasyAutoloopChannel(
|
||||
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
|
||||
¶ms.ClientRestrictions, nil, nil, 1,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, picked)
|
||||
require.Equal(
|
||||
t, ch2.ChannelID, picked.ChannelID,
|
||||
|
|
@ -92,10 +93,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) {
|
|||
)
|
||||
|
||||
// With exclusion active, peer1 should not be picked.
|
||||
picked := c.manager.pickEasyAutoloopChannel(
|
||||
[]lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions,
|
||||
nil, nil, 1,
|
||||
picked, err := c.manager.pickEasyAutoloopChannel(
|
||||
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
|
||||
¶ms.ClientRestrictions, nil, nil, 1,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, picked)
|
||||
require.Equal(t, ch2.ChannelID, picked.ChannelID)
|
||||
|
||||
|
|
@ -103,10 +105,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) {
|
|||
// CLI does before sending to the server.
|
||||
c.manager.params.EasyAutoloopExcludedPeers = nil
|
||||
|
||||
picked = c.manager.pickEasyAutoloopChannel(
|
||||
[]lndclient.ChannelInfo{ch1, ch2}, ¶ms.ClientRestrictions,
|
||||
nil, nil, 1,
|
||||
picked, err = c.manager.pickEasyAutoloopChannel(
|
||||
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
|
||||
¶ms.ClientRestrictions, nil, nil, 1,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, picked)
|
||||
require.Equal(
|
||||
t, ch1.ChannelID, picked.ChannelID,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,11 @@ type Config struct {
|
|||
LoopOutTerms func(ctx context.Context,
|
||||
initiator string) (*loop.LoopOutTerms, error)
|
||||
|
||||
// ListStaticLoopIn returns all static-address loop-ins that liquidity
|
||||
// should consider for budget accounting, in-flight limits, and peer
|
||||
// traffic.
|
||||
ListStaticLoopIn func(context.Context) ([]*StaticLoopInInfo, error)
|
||||
|
||||
// GetAssetPrice returns the price of an asset in satoshis.
|
||||
GetAssetPrice func(ctx context.Context, assetId string,
|
||||
peerPubkey []byte, assetAmt uint64,
|
||||
|
|
@ -574,9 +579,19 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Load the static loop-in snapshot once for the whole easy-autoloop
|
||||
// tick so budget and traffic checks cannot drift and do not need to hit
|
||||
// the store twice.
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get a summary of our existing swaps so that we can check our autoloop
|
||||
// budget.
|
||||
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
|
||||
summary := m.checkExistingAutoLoopsWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
)
|
||||
|
||||
err = m.checkSummaryBudget(summary)
|
||||
if err != nil {
|
||||
|
|
@ -640,9 +655,13 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
|
|||
// Start building that swap.
|
||||
builder := newLoopOutBuilder(m.cfg)
|
||||
|
||||
channel := m.pickEasyAutoloopChannel(
|
||||
usableChannels, restrictions, loopOut, loopIn, 0,
|
||||
channel, err := m.pickEasyAutoloopChannelWithStatic(
|
||||
usableChannels, restrictions, loopOut, loopIn,
|
||||
staticLoopIns, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no eligible channel for easy autoloop")
|
||||
}
|
||||
|
|
@ -721,9 +740,19 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
|
|||
return err
|
||||
}
|
||||
|
||||
// Load the static loop-in snapshot once for the whole easy-autoloop
|
||||
// tick so budget and traffic checks cannot drift and do not need to hit
|
||||
// the store twice.
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get a summary of our existing swaps so that we can check our autoloop
|
||||
// budget.
|
||||
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
|
||||
summary := m.checkExistingAutoLoopsWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
)
|
||||
|
||||
err = m.checkSummaryBudget(summary)
|
||||
if err != nil {
|
||||
|
|
@ -829,9 +858,13 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
|
|||
// Start building that swap.
|
||||
builder := newLoopOutBuilder(m.cfg)
|
||||
|
||||
channel := m.pickEasyAutoloopChannel(
|
||||
usableChannels, restrictions, loopOut, loopIn, satsPerAsset,
|
||||
channel, err := m.pickEasyAutoloopChannelWithStatic(
|
||||
usableChannels, restrictions, loopOut, loopIn,
|
||||
staticLoopIns, satsPerAsset,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if channel == nil {
|
||||
return fmt.Errorf("no eligible channel for easy autoloop")
|
||||
}
|
||||
|
|
@ -990,9 +1023,16 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get a summary of our existing swaps so that we can check our autoloop
|
||||
// budget.
|
||||
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
|
||||
summary := m.checkExistingAutoLoopsWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
)
|
||||
|
||||
err = m.checkSummaryBudget(summary)
|
||||
if err != nil {
|
||||
|
|
@ -1037,7 +1077,9 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
|
||||
// Get a summary of the channels and peers that are not eligible due
|
||||
// to ongoing swaps.
|
||||
traffic := m.currentSwapTraffic(loopOut, loopIn)
|
||||
traffic := m.currentSwapTrafficWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
)
|
||||
|
||||
var (
|
||||
suggestions []swapSuggestion
|
||||
|
|
@ -1182,6 +1224,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||
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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -1308,12 +1362,28 @@ func (e *existingAutoLoopSummary) totalFees() btcutil.Amount {
|
|||
}
|
||||
|
||||
// checkExistingAutoLoops calculates the total amount that has been spent by
|
||||
// automatically dispatched swaps that have completed, and the worst-case fee
|
||||
// total for our set of ongoing, automatically dispatched swaps as well as a
|
||||
// current in-flight count.
|
||||
func (m *Manager) checkExistingAutoLoops(_ context.Context,
|
||||
// automatically dispatched swaps that have completed, the worst-case fee total
|
||||
// for our set of ongoing automatically dispatched swaps, and the current
|
||||
// in-flight count.
|
||||
func (m *Manager) checkExistingAutoLoops(ctx context.Context,
|
||||
loopOuts []*loopdb.LoopOut,
|
||||
loopIns []*loopdb.LoopIn) *existingAutoLoopSummary {
|
||||
loopIns []*loopdb.LoopIn) (*existingAutoLoopSummary, error) {
|
||||
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.checkExistingAutoLoopsWithStatic(
|
||||
loopOuts, loopIns, staticLoopIns,
|
||||
), nil
|
||||
}
|
||||
|
||||
// checkExistingAutoLoopsWithStatic calculates our autoloop budget summary from
|
||||
// the provided swap snapshots.
|
||||
func (m *Manager) checkExistingAutoLoopsWithStatic(
|
||||
loopOuts []*loopdb.LoopOut, loopIns []*loopdb.LoopIn,
|
||||
staticLoopIns []*StaticLoopInInfo) *existingAutoLoopSummary {
|
||||
|
||||
var summary existingAutoLoopSummary
|
||||
|
||||
|
|
@ -1370,14 +1440,72 @@ func (m *Manager) checkExistingAutoLoops(_ context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
for _, in := range staticLoopIns {
|
||||
if !isAutoloopLabel(in.Label) {
|
||||
continue
|
||||
}
|
||||
|
||||
inBudget := !in.LastUpdateTime.Before(
|
||||
m.params.AutoloopBudgetLastRefresh,
|
||||
)
|
||||
|
||||
switch {
|
||||
case in.Pending:
|
||||
summary.inFlightCount++
|
||||
summary.pendingFees += staticLoopInWorstCaseFees(
|
||||
in.NumDeposits, in.HasChange, in.QuotedSwapFee,
|
||||
in.HtlcTxFeeRate, defaultLoopInSweepFee,
|
||||
)
|
||||
|
||||
case !inBudget:
|
||||
continue
|
||||
|
||||
case in.Failed:
|
||||
// Static loop-in failure accounting stays pessimistic
|
||||
// here. Once the swap is terminal we no longer know
|
||||
// from liquidity's persisted view whether the timeout
|
||||
// path actually confirmed, so we reserve the same
|
||||
// worst-case fee shape we used while the swap was in
|
||||
// flight.
|
||||
// TODO: Persist real static-address swap costs,
|
||||
// similar to loopdb.SwapCost, and use that exact
|
||||
// terminal value here instead of the pessimistic
|
||||
// worst-case estimate.
|
||||
summary.spentFees += staticLoopInWorstCaseFees(
|
||||
in.NumDeposits, in.HasChange, in.QuotedSwapFee,
|
||||
in.HtlcTxFeeRate, defaultLoopInSweepFee,
|
||||
)
|
||||
|
||||
default:
|
||||
summary.spentFees += in.QuotedSwapFee
|
||||
}
|
||||
}
|
||||
|
||||
return &summary
|
||||
}
|
||||
|
||||
// currentSwapTraffic examines our existing swaps and returns a summary of the
|
||||
// current activity which can be used to determine whether we should perform
|
||||
// any swaps.
|
||||
func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut,
|
||||
loopIn []*loopdb.LoopIn) *swapTraffic {
|
||||
func (m *Manager) currentSwapTraffic(ctx context.Context,
|
||||
loopOut []*loopdb.LoopOut,
|
||||
loopIn []*loopdb.LoopIn) (*swapTraffic, error) {
|
||||
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.currentSwapTrafficWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
), nil
|
||||
}
|
||||
|
||||
// currentSwapTrafficWithStatic builds the shared traffic view from the
|
||||
// provided swap snapshots.
|
||||
func (m *Manager) currentSwapTrafficWithStatic(loopOut []*loopdb.LoopOut,
|
||||
loopIn []*loopdb.LoopIn,
|
||||
staticLoopIns []*StaticLoopInInfo) *swapTraffic {
|
||||
|
||||
traffic := newSwapTraffic()
|
||||
|
||||
|
|
@ -1408,9 +1536,7 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut,
|
|||
|
||||
if failedAt.After(failureCutoff) {
|
||||
for _, id := range chanSet {
|
||||
chanID := lnwire.NewShortChanIDFromInt(
|
||||
id,
|
||||
)
|
||||
chanID := lnwire.NewShortChanIDFromInt(id)
|
||||
|
||||
traffic.failedLoopOut[chanID] = failedAt
|
||||
}
|
||||
|
|
@ -1464,6 +1590,22 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut,
|
|||
}
|
||||
}
|
||||
|
||||
for _, in := range staticLoopIns {
|
||||
if in.LastHop == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pubkey := *in.LastHop
|
||||
|
||||
switch {
|
||||
case in.Pending && in.BlocksLoopIn:
|
||||
traffic.ongoingLoopIn[pubkey] = true
|
||||
|
||||
case in.Failed && in.LastUpdateTime.After(failureCutoff):
|
||||
traffic.failedLoopIn[pubkey] = in.LastUpdateTime
|
||||
}
|
||||
}
|
||||
|
||||
return traffic
|
||||
}
|
||||
|
||||
|
|
@ -1651,11 +1793,34 @@ func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash,
|
|||
// This function prioritizes channels with high local balance but also consults
|
||||
// previous failures and ongoing swaps to avoid temporary channel failures or
|
||||
// swap conflicts.
|
||||
func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
|
||||
restrictions *Restrictions, loopOut []*loopdb.LoopOut,
|
||||
loopIn []*loopdb.LoopIn, satsPerAsset float64) *lndclient.ChannelInfo {
|
||||
func (m *Manager) pickEasyAutoloopChannel(ctx context.Context,
|
||||
channels []lndclient.ChannelInfo, restrictions *Restrictions,
|
||||
loopOut []*loopdb.LoopOut, loopIn []*loopdb.LoopIn,
|
||||
satsPerAsset float64) (*lndclient.ChannelInfo, error) {
|
||||
|
||||
traffic := m.currentSwapTraffic(loopOut, loopIn)
|
||||
staticLoopIns, err := m.loadStaticLoopIns(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.pickEasyAutoloopChannelWithStatic(
|
||||
channels, restrictions, loopOut, loopIn, staticLoopIns,
|
||||
satsPerAsset,
|
||||
)
|
||||
}
|
||||
|
||||
// pickEasyAutoloopChannelWithStatic picks an easy-autoloop channel using a
|
||||
// shared static loop-in snapshot so callers can reuse one store load across
|
||||
// budget and traffic checks within the same autoloop tick.
|
||||
func (m *Manager) pickEasyAutoloopChannelWithStatic(
|
||||
channels []lndclient.ChannelInfo, restrictions *Restrictions,
|
||||
loopOut []*loopdb.LoopOut, loopIn []*loopdb.LoopIn,
|
||||
staticLoopIns []*StaticLoopInInfo,
|
||||
satsPerAsset float64) (*lndclient.ChannelInfo, error) {
|
||||
|
||||
traffic := m.currentSwapTrafficWithStatic(
|
||||
loopOut, loopIn, staticLoopIns,
|
||||
)
|
||||
|
||||
// Sort the candidate channels based on descending local balance. We
|
||||
// want to prioritize picking a channel with the highest possible local
|
||||
|
|
@ -1722,13 +1887,13 @@ func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo,
|
|||
"minimum is %v, skipping remaining channels",
|
||||
channel.ChannelID, channel.LocalBalance,
|
||||
restrictions.Minimum)
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &channel
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *Manager) numActiveStickyLoops() int {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package liquidity
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -13,6 +15,7 @@ import (
|
|||
clientrpc "github.com/lightninglabs/loop/looprpc"
|
||||
"github.com/lightninglabs/loop/swap"
|
||||
"github.com/lightninglabs/loop/test"
|
||||
"github.com/lightninglabs/taproot-assets/rfqmsg"
|
||||
"github.com/lightningnetwork/lnd/clock"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
|
|
@ -169,6 +172,125 @@ func newTestConfig() (*Config, *test.LndMockServices) {
|
|||
}, lnd
|
||||
}
|
||||
|
||||
// TestSuggestSwapsLoadsStaticLoopInsOnce verifies that SuggestSwaps reuses the
|
||||
// same static loop-in snapshot for budget and traffic checks within a single
|
||||
// planner pass.
|
||||
func TestSuggestSwapsLoadsStaticLoopInsOnce(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
staticCalls := 0
|
||||
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) {
|
||||
staticCalls++
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{channel1}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
|
||||
chanID1: chanRule,
|
||||
}
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
_, err := manager.SuggestSwaps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, staticCalls)
|
||||
}
|
||||
|
||||
// TestEasyAutoloopLoadsStaticLoopInsOnce verifies that easy autoloop reuses
|
||||
// the same static loop-in snapshot for budget and traffic checks within one
|
||||
// tick.
|
||||
func TestEasyAutoloopLoadsStaticLoopInsOnce(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
staticCalls := 0
|
||||
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) {
|
||||
staticCalls++
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{
|
||||
{
|
||||
ChannelID: chanID1.ToUint64(),
|
||||
PubKeyBytes: peer1,
|
||||
LocalBalance: 90_000,
|
||||
Capacity: 100_000,
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
params.EasyAutoloop = true
|
||||
params.EasyAutoloopTarget = 50_000
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
err := manager.dispatchBestEasyAutoloopSwap(ctx)
|
||||
require.EqualError(t, err, "no eligible channel for easy autoloop")
|
||||
require.Equal(t, 1, staticCalls)
|
||||
}
|
||||
|
||||
// TestEasyAssetAutoloopLoadsStaticLoopInsOnce verifies that asset easy
|
||||
// autoloop reuses one static loop-in snapshot across budget and traffic
|
||||
// checks within the same tick.
|
||||
func TestEasyAssetAutoloopLoadsStaticLoopInsOnce(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
assetID := [32]byte{1}
|
||||
assetStr := hex.EncodeToString(assetID[:])
|
||||
|
||||
customChanData := rfqmsg.JsonAssetChannel{
|
||||
FundingAssets: []rfqmsg.JsonAssetUtxo{
|
||||
{
|
||||
AssetGenesis: rfqmsg.JsonAssetGenesis{
|
||||
AssetID: assetStr,
|
||||
},
|
||||
},
|
||||
},
|
||||
LocalBalance: 90_000,
|
||||
RemoteBalance: 0,
|
||||
Capacity: 100_000,
|
||||
}
|
||||
customChanDataBytes, err := json.Marshal(customChanData)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, lnd := newTestConfig()
|
||||
staticCalls := 0
|
||||
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo, error) {
|
||||
staticCalls++
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
cfg.GetAssetPrice = func(context.Context, string, []byte, uint64,
|
||||
btcutil.Amount) (btcutil.Amount, error) {
|
||||
|
||||
return 10_000, nil
|
||||
}
|
||||
|
||||
lnd.Channels = []lndclient.ChannelInfo{
|
||||
{
|
||||
ChannelID: chanID1.ToUint64(),
|
||||
PubKeyBytes: peer1,
|
||||
CustomChannelData: customChanDataBytes,
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
err = manager.dispatchBestAssetEasyAutoloopSwap(ctx, assetStr, 50_000)
|
||||
require.EqualError(t, err, "no eligible channel for easy autoloop")
|
||||
require.Equal(t, 1, staticCalls)
|
||||
}
|
||||
|
||||
// testPPMFees calculates the split of fees between prepay and swap invoice
|
||||
// for the swap amount and ppm, relying on the test quote.
|
||||
func testPPMFees(ppm uint64, quote *loop.LoopOutQuote,
|
||||
|
|
@ -2038,12 +2160,14 @@ func TestCurrentTraffic(t *testing.T) {
|
|||
for _, testCase := range tests {
|
||||
cfg, _ := newTestConfig()
|
||||
m := NewManager(cfg)
|
||||
ctx := t.Context()
|
||||
|
||||
params := m.GetParameters()
|
||||
params.FailureBackOff = backoff
|
||||
require.NoError(t, m.setParameters(context.Background(), params))
|
||||
require.NoError(t, m.setParameters(ctx, params))
|
||||
|
||||
actual := m.currentSwapTraffic(testCase.loopOut, testCase.loopIn)
|
||||
actual, err := m.currentSwapTraffic(ctx, testCase.loopOut, testCase.loopIn)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expected, actual)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
liquidity/static_loopin.go
Normal file
122
liquidity/static_loopin.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package liquidity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/txscript"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
// StaticLoopInInfo contains the persisted data that liquidity needs for budget
|
||||
// accounting and peer traffic tracking.
|
||||
type StaticLoopInInfo struct {
|
||||
// Label identifies whether the swap belongs to autoloop.
|
||||
Label string
|
||||
|
||||
// QuotedSwapFee is the quoted server fee for the swap.
|
||||
QuotedSwapFee btcutil.Amount
|
||||
|
||||
// HtlcTxFeeRate is the stored HTLC transaction fee rate for the swap's
|
||||
// timeout path. This is only known once the static loop-in has been
|
||||
// initiated and the server has proposed concrete HTLC transactions.
|
||||
HtlcTxFeeRate chainfee.SatPerKWeight
|
||||
|
||||
// LastHop identifies the target peer when the swap is peer-restricted.
|
||||
LastHop *route.Vertex
|
||||
|
||||
// LastUpdateTime is the timestamp of the latest persisted state update.
|
||||
LastUpdateTime time.Time
|
||||
|
||||
// Pending indicates whether the swap is still in flight and therefore
|
||||
// needs worst-case fee reservation in the current budget window.
|
||||
Pending bool
|
||||
|
||||
// Failed indicates whether the swap reached a terminal failure state.
|
||||
// Liquidity uses this to apply conservative fee accounting and recent
|
||||
// failure backoff for the peer.
|
||||
Failed bool
|
||||
|
||||
// BlocksLoopIn indicates whether the swap should currently block new
|
||||
// loop-in suggestions for its peer. Static swaps stop blocking once the
|
||||
// off-chain payment has been received.
|
||||
BlocksLoopIn bool
|
||||
|
||||
// NumDeposits is the number of deposits locked into the swap.
|
||||
NumDeposits int
|
||||
|
||||
// HasChange indicates whether the swap selected less than the total
|
||||
// value of its deposits and therefore produced change.
|
||||
HasChange bool
|
||||
}
|
||||
|
||||
// staticLoopInWorstCaseFees returns the larger of the cooperative success fee
|
||||
// and the timeout-path fee for a static loop-in.
|
||||
func staticLoopInWorstCaseFees(numDeposits int, hasChange bool,
|
||||
swapFee btcutil.Amount, htlcFeeRate,
|
||||
timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount {
|
||||
|
||||
successFee := swapFee
|
||||
|
||||
timeoutFee := staticLoopInOnchainFee(
|
||||
numDeposits, hasChange, htlcFeeRate, timeoutSweepFeeRate,
|
||||
)
|
||||
|
||||
return max(timeoutFee, successFee)
|
||||
}
|
||||
|
||||
// staticLoopInOnchainFee estimates the fee for the server-published HTLC
|
||||
// transaction and client sweep transaction.
|
||||
func staticLoopInOnchainFee(numDeposits int, hasChange bool, htlcFeeRate,
|
||||
timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount {
|
||||
|
||||
htlcFeeRate = staticLoopInHtlcFeeRate(
|
||||
htlcFeeRate, timeoutSweepFeeRate,
|
||||
)
|
||||
|
||||
htlcFee := htlcFeeRate.FeeForWeight(
|
||||
staticLoopInHtlcWeight(numDeposits, hasChange),
|
||||
)
|
||||
|
||||
sweepFee := loopInSweepFee(timeoutSweepFeeRate)
|
||||
|
||||
return htlcFee + sweepFee
|
||||
}
|
||||
|
||||
// staticLoopInHtlcFeeRate returns the best HTLC fee rate known to the planner.
|
||||
// Pending static loop-ins do not persist their concrete HTLC fee rate until the
|
||||
// server returns the HTLC package, so liquidity has to reuse the same
|
||||
// conservative fallback it already uses for dry-run filtering when the stored
|
||||
// rate is still zero.
|
||||
func staticLoopInHtlcFeeRate(htlcFeeRate,
|
||||
timeoutSweepFeeRate chainfee.SatPerKWeight) chainfee.SatPerKWeight {
|
||||
|
||||
if htlcFeeRate == 0 {
|
||||
return timeoutSweepFeeRate
|
||||
}
|
||||
|
||||
return htlcFeeRate
|
||||
}
|
||||
|
||||
// staticLoopInHtlcWeight returns the HTLC transaction weight for a static loop
|
||||
// in with the given number of deposits.
|
||||
func staticLoopInHtlcWeight(numDeposits int,
|
||||
hasChange bool) lntypes.WeightUnit {
|
||||
|
||||
var estimator input.TxWeightEstimator
|
||||
|
||||
for range numDeposits {
|
||||
estimator.AddTaprootKeySpendInput(txscript.SigHashDefault)
|
||||
}
|
||||
|
||||
estimator.AddP2WSHOutput()
|
||||
|
||||
if hasChange {
|
||||
estimator.AddP2TROutput()
|
||||
}
|
||||
|
||||
return estimator.Weight()
|
||||
}
|
||||
276
liquidity/static_loopin_test.go
Normal file
276
liquidity/static_loopin_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package liquidity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightninglabs/loop/labels"
|
||||
"github.com/lightninglabs/loop/swap"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestStaticLoopInHtlcWeight verifies the exact HTLC transaction weight for
|
||||
// the supported deposit-count and change-shape combinations.
|
||||
func TestStaticLoopInHtlcWeight(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
numDeposits int
|
||||
hasChange bool
|
||||
expected int64
|
||||
}{
|
||||
{
|
||||
name: "zero deposits no change",
|
||||
numDeposits: 0,
|
||||
expected: 212,
|
||||
},
|
||||
{
|
||||
name: "zero deposits with change",
|
||||
numDeposits: 0,
|
||||
hasChange: true,
|
||||
expected: 384,
|
||||
},
|
||||
{
|
||||
name: "single deposit no change",
|
||||
numDeposits: 1,
|
||||
expected: 444,
|
||||
},
|
||||
{
|
||||
name: "multiple deposits with change",
|
||||
numDeposits: 3,
|
||||
hasChange: true,
|
||||
expected: 1076,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
weight := staticLoopInHtlcWeight(
|
||||
testCase.numDeposits, testCase.hasChange,
|
||||
)
|
||||
|
||||
require.Equal(t, testCase.expected, int64(weight))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticLoopInOnchainFee verifies that the HTLC publish fee and timeout
|
||||
// sweep fee are combined correctly across the supported shapes.
|
||||
func TestStaticLoopInOnchainFee(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
numDeposits int
|
||||
hasChange bool
|
||||
htlcFeeRate chainfee.SatPerKWeight
|
||||
timeoutSweepFeeRate chainfee.SatPerKWeight
|
||||
expected btcutil.Amount
|
||||
}{
|
||||
{
|
||||
name: "zero fee rates",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "single deposit without change",
|
||||
numDeposits: 1,
|
||||
htlcFeeRate: chainfee.SatPerKWeight(1_200),
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(800),
|
||||
expected: 884,
|
||||
},
|
||||
{
|
||||
name: "multiple deposits with change",
|
||||
numDeposits: 3,
|
||||
hasChange: true,
|
||||
htlcFeeRate: chainfee.SatPerKWeight(2_500),
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(1_700),
|
||||
expected: 3439,
|
||||
},
|
||||
{
|
||||
name: "zero htlc fee rate falls back to timeout fee " +
|
||||
"rate",
|
||||
numDeposits: 2,
|
||||
htlcFeeRate: 0,
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(1_100),
|
||||
expected: chainfee.SatPerKWeight(1_100).FeeForWeight(
|
||||
staticLoopInHtlcWeight(2, false),
|
||||
) + loopInSweepFee(chainfee.SatPerKWeight(1_100)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
onchainFee := staticLoopInOnchainFee(
|
||||
testCase.numDeposits, testCase.hasChange,
|
||||
testCase.htlcFeeRate,
|
||||
testCase.timeoutSweepFeeRate,
|
||||
)
|
||||
|
||||
require.Equal(t, testCase.expected, onchainFee)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticLoopInWorstCaseFees verifies that the helper chooses the larger
|
||||
// of the cooperative success fee and timeout-path fee.
|
||||
func TestStaticLoopInWorstCaseFees(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
numDeposits int
|
||||
hasChange bool
|
||||
swapFee btcutil.Amount
|
||||
htlcFeeRate chainfee.SatPerKWeight
|
||||
timeoutSweepFeeRate chainfee.SatPerKWeight
|
||||
expected btcutil.Amount
|
||||
}{
|
||||
{
|
||||
name: "all fees zero",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "returns success fee when larger",
|
||||
numDeposits: 1,
|
||||
swapFee: 5_000,
|
||||
htlcFeeRate: chainfee.SatPerKWeight(800),
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(700),
|
||||
expected: 5_000,
|
||||
},
|
||||
{
|
||||
name: "returns timeout fee when larger",
|
||||
numDeposits: 2,
|
||||
hasChange: true,
|
||||
swapFee: 1_000,
|
||||
htlcFeeRate: chainfee.SatPerKWeight(5_000),
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(3_000),
|
||||
expected: 5553,
|
||||
},
|
||||
{
|
||||
name: "returns equal fee when paths match",
|
||||
numDeposits: 1,
|
||||
swapFee: 884,
|
||||
htlcFeeRate: chainfee.SatPerKWeight(1_200),
|
||||
timeoutSweepFeeRate: chainfee.SatPerKWeight(800),
|
||||
expected: 884,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
require.Equal(
|
||||
t, testCase.expected,
|
||||
staticLoopInWorstCaseFees(
|
||||
testCase.numDeposits,
|
||||
testCase.hasChange,
|
||||
testCase.swapFee, testCase.htlcFeeRate,
|
||||
testCase.timeoutSweepFeeRate,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckExistingAutoLoopsStatic verifies that static autoloops contribute
|
||||
// to the same budget summary as legacy autoloops.
|
||||
func TestCheckExistingAutoLoopsStatic(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
sampleStaticLoopIns := []*StaticLoopInInfo{
|
||||
{
|
||||
Label: labels.AutoloopLabel(swap.TypeIn),
|
||||
QuotedSwapFee: 50,
|
||||
LastUpdateTime: testTime,
|
||||
},
|
||||
{
|
||||
Label: labels.AutoloopLabel(swap.TypeIn),
|
||||
QuotedSwapFee: 80,
|
||||
LastUpdateTime: testTime,
|
||||
Pending: true,
|
||||
NumDeposits: 2,
|
||||
},
|
||||
{
|
||||
Label: labels.AutoloopLabel(swap.TypeIn),
|
||||
QuotedSwapFee: 70,
|
||||
HtlcTxFeeRate: chainfee.SatPerKWeight(800),
|
||||
LastUpdateTime: testTime,
|
||||
Failed: true,
|
||||
NumDeposits: 1,
|
||||
},
|
||||
}
|
||||
|
||||
cfg, _ := newTestConfig()
|
||||
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo,
|
||||
error) {
|
||||
|
||||
return sampleStaticLoopIns, nil
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.AutoloopBudgetLastRefresh = testBudgetStart
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
summary, err := manager.checkExistingAutoLoops(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, summary.inFlightCount)
|
||||
require.Equal(
|
||||
t,
|
||||
btcutil.Amount(50)+staticLoopInWorstCaseFees(
|
||||
1, false, 70, chainfee.SatPerKWeight(800),
|
||||
defaultLoopInSweepFee,
|
||||
),
|
||||
summary.spentFees,
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
staticLoopInWorstCaseFees(
|
||||
2, false, 80, 0,
|
||||
defaultLoopInSweepFee,
|
||||
),
|
||||
summary.pendingFees,
|
||||
)
|
||||
}
|
||||
|
||||
// TestCurrentSwapTrafficStatic verifies that static loop-ins contribute peer
|
||||
// blocking and failure backoff information to the shared traffic summary.
|
||||
func TestCurrentSwapTrafficStatic(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
cfg, _ := newTestConfig()
|
||||
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo,
|
||||
error) {
|
||||
|
||||
return []*StaticLoopInInfo{
|
||||
{
|
||||
LastHop: &peer1,
|
||||
LastUpdateTime: testTime,
|
||||
Pending: true,
|
||||
BlocksLoopIn: true,
|
||||
},
|
||||
{
|
||||
LastHop: &peer2,
|
||||
LastUpdateTime: testTime,
|
||||
Failed: true,
|
||||
},
|
||||
{
|
||||
LastHop: &route.Vertex{3},
|
||||
LastUpdateTime: testTime,
|
||||
Pending: true,
|
||||
BlocksLoopIn: false,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
manager := NewManager(cfg)
|
||||
params := manager.GetParameters()
|
||||
params.FailureBackOff = time.Hour
|
||||
require.NoError(t, manager.setParameters(ctx, params))
|
||||
|
||||
traffic, err := manager.currentSwapTraffic(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, traffic.ongoingLoopIn[peer1])
|
||||
require.False(t, traffic.ongoingLoopIn[route.Vertex{3}])
|
||||
require.Equal(t, testTime, traffic.failedLoopIn[peer2])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue