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:
Boris Nagaev 2026-04-10 23:51:19 -05:00
parent 94fc04a71a
commit 7cf0a87c2b
No known key found for this signature in database
10 changed files with 822 additions and 47 deletions

View file

@ -47,10 +47,11 @@ func TestEasyAutoloopExcludedPeers(t *testing.T) {
) )
// Picking a channel should not pick the excluded peer's channel. // Picking a channel should not pick the excluded peer's channel.
picked := c.manager.pickEasyAutoloopChannel( picked, err := c.manager.pickEasyAutoloopChannel(
[]lndclient.ChannelInfo{ch1, ch2}, &params.ClientRestrictions, t.Context(), []lndclient.ChannelInfo{ch1, ch2},
nil, nil, 1, &params.ClientRestrictions, nil, nil, 1,
) )
require.NoError(t, err)
require.NotNil(t, picked) require.NotNil(t, picked)
require.Equal( require.Equal(
t, ch2.ChannelID, picked.ChannelID, t, ch2.ChannelID, picked.ChannelID,
@ -92,10 +93,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) {
) )
// With exclusion active, peer1 should not be picked. // With exclusion active, peer1 should not be picked.
picked := c.manager.pickEasyAutoloopChannel( picked, err := c.manager.pickEasyAutoloopChannel(
[]lndclient.ChannelInfo{ch1, ch2}, &params.ClientRestrictions, t.Context(), []lndclient.ChannelInfo{ch1, ch2},
nil, nil, 1, &params.ClientRestrictions, nil, nil, 1,
) )
require.NoError(t, err)
require.NotNil(t, picked) require.NotNil(t, picked)
require.Equal(t, ch2.ChannelID, picked.ChannelID) require.Equal(t, ch2.ChannelID, picked.ChannelID)
@ -103,10 +105,11 @@ func TestEasyAutoloopIncludeAllPeers(t *testing.T) {
// CLI does before sending to the server. // CLI does before sending to the server.
c.manager.params.EasyAutoloopExcludedPeers = nil c.manager.params.EasyAutoloopExcludedPeers = nil
picked = c.manager.pickEasyAutoloopChannel( picked, err = c.manager.pickEasyAutoloopChannel(
[]lndclient.ChannelInfo{ch1, ch2}, &params.ClientRestrictions, t.Context(), []lndclient.ChannelInfo{ch1, ch2},
nil, nil, 1, &params.ClientRestrictions, nil, nil, 1,
) )
require.NoError(t, err)
require.NotNil(t, picked) require.NotNil(t, picked)
require.Equal( require.Equal(
t, ch1.ChannelID, picked.ChannelID, t, ch1.ChannelID, picked.ChannelID,

View file

@ -221,6 +221,11 @@ type Config struct {
LoopOutTerms func(ctx context.Context, LoopOutTerms func(ctx context.Context,
initiator string) (*loop.LoopOutTerms, error) 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 returns the price of an asset in satoshis.
GetAssetPrice func(ctx context.Context, assetId string, GetAssetPrice func(ctx context.Context, assetId string,
peerPubkey []byte, assetAmt uint64, peerPubkey []byte, assetAmt uint64,
@ -574,9 +579,19 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
return err 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 // Get a summary of our existing swaps so that we can check our autoloop
// budget. // budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) summary := m.checkExistingAutoLoopsWithStatic(
loopOut, loopIn, staticLoopIns,
)
err = m.checkSummaryBudget(summary) err = m.checkSummaryBudget(summary)
if err != nil { if err != nil {
@ -640,9 +655,13 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
// Start building that swap. // Start building that swap.
builder := newLoopOutBuilder(m.cfg) builder := newLoopOutBuilder(m.cfg)
channel := m.pickEasyAutoloopChannel( channel, err := m.pickEasyAutoloopChannelWithStatic(
usableChannels, restrictions, loopOut, loopIn, 0, usableChannels, restrictions, loopOut, loopIn,
staticLoopIns, 0,
) )
if err != nil {
return err
}
if channel == nil { if channel == nil {
return fmt.Errorf("no eligible channel for easy autoloop") return fmt.Errorf("no eligible channel for easy autoloop")
} }
@ -721,9 +740,19 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
return err 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 // Get a summary of our existing swaps so that we can check our autoloop
// budget. // budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) summary := m.checkExistingAutoLoopsWithStatic(
loopOut, loopIn, staticLoopIns,
)
err = m.checkSummaryBudget(summary) err = m.checkSummaryBudget(summary)
if err != nil { if err != nil {
@ -829,9 +858,13 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
// Start building that swap. // Start building that swap.
builder := newLoopOutBuilder(m.cfg) builder := newLoopOutBuilder(m.cfg)
channel := m.pickEasyAutoloopChannel( channel, err := m.pickEasyAutoloopChannelWithStatic(
usableChannels, restrictions, loopOut, loopIn, satsPerAsset, usableChannels, restrictions, loopOut, loopIn,
staticLoopIns, satsPerAsset,
) )
if err != nil {
return err
}
if channel == nil { if channel == nil {
return fmt.Errorf("no eligible channel for easy autoloop") return fmt.Errorf("no eligible channel for easy autoloop")
} }
@ -990,9 +1023,16 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
return nil, err 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 // Get a summary of our existing swaps so that we can check our autoloop
// budget. // budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) summary := m.checkExistingAutoLoopsWithStatic(
loopOut, loopIn, staticLoopIns,
)
err = m.checkSummaryBudget(summary) err = m.checkSummaryBudget(summary)
if err != nil { 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 // Get a summary of the channels and peers that are not eligible due
// to ongoing swaps. // to ongoing swaps.
traffic := m.currentSwapTraffic(loopOut, loopIn) traffic := m.currentSwapTrafficWithStatic(
loopOut, loopIn, staticLoopIns,
)
var ( var (
suggestions []swapSuggestion suggestions []swapSuggestion
@ -1182,6 +1224,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
return resp, nil 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 // suggestSwap checks whether we can currently perform a swap, and creates a
// swap request for the rule provided. // swap request for the rule provided.
func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, 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 // checkExistingAutoLoops calculates the total amount that has been spent by
// automatically dispatched swaps that have completed, and the worst-case fee // automatically dispatched swaps that have completed, the worst-case fee total
// total for our set of ongoing, automatically dispatched swaps as well as a // for our set of ongoing automatically dispatched swaps, and the current
// current in-flight count. // in-flight count.
func (m *Manager) checkExistingAutoLoops(_ context.Context, func (m *Manager) checkExistingAutoLoops(ctx context.Context,
loopOuts []*loopdb.LoopOut, 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 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 return &summary
} }
// currentSwapTraffic examines our existing swaps and returns a summary of the // currentSwapTraffic examines our existing swaps and returns a summary of the
// current activity which can be used to determine whether we should perform // current activity which can be used to determine whether we should perform
// any swaps. // any swaps.
func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut, func (m *Manager) currentSwapTraffic(ctx context.Context,
loopIn []*loopdb.LoopIn) *swapTraffic { 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() traffic := newSwapTraffic()
@ -1408,9 +1536,7 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut,
if failedAt.After(failureCutoff) { if failedAt.After(failureCutoff) {
for _, id := range chanSet { for _, id := range chanSet {
chanID := lnwire.NewShortChanIDFromInt( chanID := lnwire.NewShortChanIDFromInt(id)
id,
)
traffic.failedLoopOut[chanID] = failedAt 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 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 // This function prioritizes channels with high local balance but also consults
// previous failures and ongoing swaps to avoid temporary channel failures or // previous failures and ongoing swaps to avoid temporary channel failures or
// swap conflicts. // swap conflicts.
func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo, func (m *Manager) pickEasyAutoloopChannel(ctx context.Context,
restrictions *Restrictions, loopOut []*loopdb.LoopOut, channels []lndclient.ChannelInfo, restrictions *Restrictions,
loopIn []*loopdb.LoopIn, satsPerAsset float64) *lndclient.ChannelInfo { 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 // Sort the candidate channels based on descending local balance. We
// want to prioritize picking a channel with the highest possible local // 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", "minimum is %v, skipping remaining channels",
channel.ChannelID, channel.LocalBalance, channel.ChannelID, channel.LocalBalance,
restrictions.Minimum) restrictions.Minimum)
return nil return nil, nil
} }
return &channel return &channel, nil
} }
return nil return nil, nil
} }
func (m *Manager) numActiveStickyLoops() int { func (m *Manager) numActiveStickyLoops() int {

View file

@ -2,6 +2,8 @@ package liquidity
import ( import (
"context" "context"
"encoding/hex"
"encoding/json"
"testing" "testing"
"time" "time"
@ -13,6 +15,7 @@ import (
clientrpc "github.com/lightninglabs/loop/looprpc" clientrpc "github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/test" "github.com/lightninglabs/loop/test"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
@ -169,6 +172,125 @@ func newTestConfig() (*Config, *test.LndMockServices) {
}, lnd }, 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 // testPPMFees calculates the split of fees between prepay and swap invoice
// for the swap amount and ppm, relying on the test quote. // for the swap amount and ppm, relying on the test quote.
func testPPMFees(ppm uint64, quote *loop.LoopOutQuote, func testPPMFees(ppm uint64, quote *loop.LoopOutQuote,
@ -2038,12 +2160,14 @@ func TestCurrentTraffic(t *testing.T) {
for _, testCase := range tests { for _, testCase := range tests {
cfg, _ := newTestConfig() cfg, _ := newTestConfig()
m := NewManager(cfg) m := NewManager(cfg)
ctx := t.Context()
params := m.GetParameters() params := m.GetParameters()
params.FailureBackOff = backoff 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) require.Equal(t, testCase.expected, actual)
} }
} }

122
liquidity/static_loopin.go Normal file
View 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()
}

View 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])
}

View file

@ -734,12 +734,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
) )
} }
liquidityMgr := getLiquidityManager(
swapClient, staticLoopInManager,
)
// Now finally fully initialize the swap client RPC server instance. // Now finally fully initialize the swap client RPC server instance.
d.swapClientServer = swapClientServer{ d.swapClientServer = swapClientServer{
config: d.cfg, config: d.cfg,
network: lndclient.Network(d.cfg.Network), network: lndclient.Network(d.cfg.Network),
impl: swapClient, impl: swapClient,
liquidityMgr: getLiquidityManager(swapClient), liquidityMgr: liquidityMgr,
lnd: &d.lnd.LndServices, lnd: &d.lnd.LndServices,
swaps: make(map[lntypes.Hash]loop.SwapInfo), swaps: make(map[lntypes.Hash]loop.SwapInfo),
subscribers: make(map[int]chan<- any), subscribers: make(map[int]chan<- any),

View file

@ -3,6 +3,7 @@ package loopd
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
@ -12,6 +13,7 @@ import (
"github.com/lightninglabs/loop/assets" "github.com/lightninglabs/loop/assets"
"github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/liquidity"
"github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/staticaddr/loopin"
"github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/sweepbatcher" "github.com/lightninglabs/loop/sweepbatcher"
"github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/clock"
@ -115,7 +117,50 @@ func openDatabase(cfg *Config, chainParams *chaincfg.Params) (loopdb.SwapStore,
return db, &baseDb, nil return db, &baseDb, nil
} }
func getLiquidityManager(client *loop.Client) *liquidity.Manager { func getLiquidityManager(client *loop.Client,
staticLoopInManager *loopin.Manager) *liquidity.Manager {
listStaticLoopIn := func(
ctx context.Context) ([]*liquidity.StaticLoopInInfo, error) {
if staticLoopInManager == nil {
return nil, nil
}
swaps, err := staticLoopInManager.GetAllSwaps(ctx)
if err != nil {
return nil, err
}
result := make(
[]*liquidity.StaticLoopInInfo, 0, len(swaps),
)
for _, staticSwap := range swaps {
state := staticSwap.GetState()
pending := slices.Contains(loopin.PendingStates, state)
failed := state == loopin.Failed ||
state == loopin.HtlcTimeoutSwept
result = append(result, &liquidity.StaticLoopInInfo{
Label: staticSwap.Label,
QuotedSwapFee: staticSwap.QuotedSwapFee,
HtlcTxFeeRate: staticSwap.HtlcTxFeeRate,
LastHop: staticSwap.LastHopVertex(),
LastUpdateTime: staticSwap.LastUpdateTime,
Pending: pending,
Failed: failed,
BlocksLoopIn: pending &&
state != loopin.PaymentReceived,
NumDeposits: len(staticSwap.Deposits),
HasChange: staticSwap.SelectedAmount > 0 &&
staticSwap.SelectedAmount <
staticSwap.TotalDepositAmount(),
})
}
return result, nil
}
mngrCfg := &liquidity.Config{ mngrCfg := &liquidity.Config{
AutoloopTicker: ticker.NewForce(liquidity.DefaultAutoloopTicker), AutoloopTicker: ticker.NewForce(liquidity.DefaultAutoloopTicker),
LoopOut: client.LoopOut, LoopOut: client.LoopOut,
@ -150,6 +195,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager {
ListLoopOut: client.Store.FetchLoopOutSwaps, ListLoopOut: client.Store.FetchLoopOutSwaps,
GetLoopOut: client.Store.FetchLoopOutSwap, GetLoopOut: client.Store.FetchLoopOutSwap,
ListLoopIn: client.Store.FetchLoopInSwaps, ListLoopIn: client.Store.FetchLoopInSwaps,
ListStaticLoopIn: listStaticLoopIn,
LoopInTerms: client.LoopInTerms, LoopInTerms: client.LoopInTerms,
LoopOutTerms: client.LoopOutTerms, LoopOutTerms: client.LoopOutTerms,
GetAssetPrice: client.AssetClient.GetAssetPrice, GetAssetPrice: client.AssetClient.GetAssetPrice,

View file

@ -27,6 +27,7 @@ import (
"github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32" "github.com/lightningnetwork/lnd/zpay32"
) )
@ -105,6 +106,9 @@ type StaticAddressLoopIn struct {
// on the server side for this static loop in. // on the server side for this static loop in.
Fast bool Fast bool
// LastUpdateTime is the timestamp of the latest persisted state update.
LastUpdateTime time.Time
// state is the current state of the swap. // state is the current state of the swap.
state fsm.StateType state fsm.StateType
@ -486,6 +490,21 @@ func (l *StaticAddressLoopIn) Outpoints() []wire.OutPoint {
return outpoints return outpoints
} }
// LastHopVertex returns the swap's last hop as a route vertex when the field
// is present and well formed.
func (l *StaticAddressLoopIn) LastHopVertex() *route.Vertex {
if len(l.LastHop) == 0 {
return nil
}
vertex, err := route.NewVertexFromBytes(l.LastHop)
if err != nil {
return nil
}
return &vertex
}
// GetState returns the current state of the loop-in swap. // GetState returns the current state of the loop-in swap.
func (l *StaticAddressLoopIn) GetState() fsm.StateType { func (l *StaticAddressLoopIn) GetState() fsm.StateType {
l.mu.Lock() l.mu.Lock()

View file

@ -591,6 +591,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params,
if len(updates) > 0 { if len(updates) > 0 {
lastUpdate := updates[len(updates)-1] lastUpdate := updates[len(updates)-1]
loopIn.SetState(fsm.StateType(lastUpdate.UpdateState)) loopIn.SetState(fsm.StateType(lastUpdate.UpdateState))
loopIn.LastUpdateTime = lastUpdate.UpdateTimestamp
} }
return loopIn, nil return loopIn, nil

View file

@ -159,7 +159,7 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) {
// StaticAddressLoopIn swap and associates it with the provided deposits. // StaticAddressLoopIn swap and associates it with the provided deposits.
func TestCreateLoopIn(t *testing.T) { func TestCreateLoopIn(t *testing.T) {
// Set up test context objects. // Set up test context objects.
ctxb := context.Background() ctx := t.Context()
testDb := loopdb.NewTestDB(t) testDb := loopdb.NewTestDB(t)
testClock := clock.NewTestClock(time.Now()) testClock := clock.NewTestClock(time.Now())
defer testDb.Close() defer testDb.Close()
@ -200,17 +200,17 @@ func TestCreateLoopIn(t *testing.T) {
}, },
} }
err := depositStore.CreateDeposit(ctxb, d1) err := depositStore.CreateDeposit(ctx, d1)
require.NoError(t, err) require.NoError(t, err)
err = depositStore.CreateDeposit(ctxb, d2) err = depositStore.CreateDeposit(ctx, d2)
require.NoError(t, err) require.NoError(t, err)
d1.SetState(deposit.LoopingIn) d1.SetState(deposit.LoopingIn)
d2.SetState(deposit.LoopingIn) d2.SetState(deposit.LoopingIn)
err = depositStore.UpdateDeposit(ctxb, d1) err = depositStore.UpdateDeposit(ctx, d1)
require.NoError(t, err) require.NoError(t, err)
err = depositStore.UpdateDeposit(ctxb, d2) err = depositStore.UpdateDeposit(ctx, d2)
require.NoError(t, err) require.NoError(t, err)
_, clientPubKey := test.CreateKey(1) _, clientPubKey := test.CreateKey(1)
@ -232,11 +232,11 @@ func TestCreateLoopIn(t *testing.T) {
} }
swapPending.SetState(SignHtlcTx) swapPending.SetState(SignHtlcTx)
err = swapStore.CreateLoopIn(ctxb, &swapPending) err = swapStore.CreateLoopIn(ctx, &swapPending)
require.NoError(t, err) require.NoError(t, err)
depositIDs, err := swapStore.DepositIDsForSwapHash( depositIDs, err := swapStore.DepositIDsForSwapHash(
ctxb, swapHashPending, ctx, swapHashPending,
) )
require.NoError(t, err) require.NoError(t, err)
require.Len(t, depositIDs, 2) require.Len(t, depositIDs, 2)
@ -244,7 +244,7 @@ func TestCreateLoopIn(t *testing.T) {
require.Contains(t, depositIDs, d2.ID) require.Contains(t, depositIDs, d2.ID)
swapHashes, err := swapStore.SwapHashesForDepositIDs( swapHashes, err := swapStore.SwapHashesForDepositIDs(
ctxb, []deposit.ID{depositIDs[0], depositIDs[1]}, ctx, []deposit.ID{depositIDs[0], depositIDs[1]},
) )
require.NoError(t, err) require.NoError(t, err)
require.Len(t, swapHashes, 1) require.Len(t, swapHashes, 1)
@ -252,7 +252,7 @@ func TestCreateLoopIn(t *testing.T) {
require.Contains(t, swapHashes[swapHashPending], depositIDs[0]) require.Contains(t, swapHashes[swapHashPending], depositIDs[0])
require.Contains(t, swapHashes[swapHashPending], depositIDs[1]) require.Contains(t, swapHashes[swapHashPending], depositIDs[1])
swap, err := swapStore.GetLoopInByHash(ctxb, swapHashPending) swap, err := swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, swapHashPending, swap.SwapHash) require.Equal(t, swapHashPending, swap.SwapHash)
require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()}, require.Equal(t, []string{d1.OutPoint.String(), d2.OutPoint.String()},
@ -270,4 +270,19 @@ func TestCreateLoopIn(t *testing.T) {
require.Equal(t, d2.OutPoint, swap.Deposits[1].OutPoint) require.Equal(t, d2.OutPoint, swap.Deposits[1].OutPoint)
require.Equal(t, d2.Value, swap.Deposits[1].Value) require.Equal(t, d2.Value, swap.Deposits[1].Value)
require.Equal(t, deposit.LoopingIn, swap.Deposits[1].GetState()) require.Equal(t, deposit.LoopingIn, swap.Deposits[1].GetState())
updateTime := testClock.Now().Add(time.Minute)
testClock.SetTime(updateTime)
swapPending.SetState(Succeeded)
err = swapStore.UpdateLoopIn(ctx, &swapPending)
require.NoError(t, err)
swap, err = swapStore.GetLoopInByHash(ctx, swapHashPending)
require.NoError(t, err)
require.Equal(t, Succeeded, swap.GetState())
require.WithinDuration(
t, updateTime.UTC(), swap.LastUpdateTime.UTC(),
time.Microsecond,
)
} }