diff --git a/liquidity/easy_autoloop_exclusions_test.go b/liquidity/easy_autoloop_exclusions_test.go index b70af97c..e6e23627 100644 --- a/liquidity/easy_autoloop_exclusions_test.go +++ b/liquidity/easy_autoloop_exclusions_test.go @@ -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, diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index b31e2b00..ceb735e4 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -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 { diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index 3824ce9c..77744733 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -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) } } diff --git a/liquidity/static_loopin.go b/liquidity/static_loopin.go new file mode 100644 index 00000000..338b64b2 --- /dev/null +++ b/liquidity/static_loopin.go @@ -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() +} diff --git a/liquidity/static_loopin_test.go b/liquidity/static_loopin_test.go new file mode 100644 index 00000000..12b227c4 --- /dev/null +++ b/liquidity/static_loopin_test.go @@ -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]) +} diff --git a/loopd/daemon.go b/loopd/daemon.go index 880e1962..9b00f4e0 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -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. d.swapClientServer = swapClientServer{ config: d.cfg, network: lndclient.Network(d.cfg.Network), impl: swapClient, - liquidityMgr: getLiquidityManager(swapClient), + liquidityMgr: liquidityMgr, lnd: &d.lnd.LndServices, swaps: make(map[lntypes.Hash]loop.SwapInfo), subscribers: make(map[int]chan<- any), diff --git a/loopd/utils.go b/loopd/utils.go index 9b439ac5..5a98ae97 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -3,6 +3,7 @@ package loopd import ( "context" "fmt" + "slices" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -12,6 +13,7 @@ import ( "github.com/lightninglabs/loop/assets" "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/staticaddr/loopin" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/sweepbatcher" "github.com/lightningnetwork/lnd/clock" @@ -115,7 +117,50 @@ func openDatabase(cfg *Config, chainParams *chaincfg.Params) (loopdb.SwapStore, 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{ AutoloopTicker: ticker.NewForce(liquidity.DefaultAutoloopTicker), LoopOut: client.LoopOut, @@ -150,6 +195,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager { ListLoopOut: client.Store.FetchLoopOutSwaps, GetLoopOut: client.Store.FetchLoopOutSwap, ListLoopIn: client.Store.FetchLoopInSwaps, + ListStaticLoopIn: listStaticLoopIn, LoopInTerms: client.LoopInTerms, LoopOutTerms: client.LoopOutTerms, GetAssetPrice: client.AssetClient.GetAssetPrice, diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index bf0c434b..0be2ffe4 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -27,6 +27,7 @@ import ( "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" ) @@ -105,6 +106,9 @@ type StaticAddressLoopIn struct { // on the server side for this static loop in. Fast bool + // LastUpdateTime is the timestamp of the latest persisted state update. + LastUpdateTime time.Time + // state is the current state of the swap. state fsm.StateType @@ -486,6 +490,21 @@ func (l *StaticAddressLoopIn) Outpoints() []wire.OutPoint { 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. func (l *StaticAddressLoopIn) GetState() fsm.StateType { l.mu.Lock() diff --git a/staticaddr/loopin/sql_store.go b/staticaddr/loopin/sql_store.go index 1b70bbc4..d06c5c18 100644 --- a/staticaddr/loopin/sql_store.go +++ b/staticaddr/loopin/sql_store.go @@ -591,6 +591,7 @@ func toStaticAddressLoopIn(_ context.Context, network *chaincfg.Params, if len(updates) > 0 { lastUpdate := updates[len(updates)-1] loopIn.SetState(fsm.StateType(lastUpdate.UpdateState)) + loopIn.LastUpdateTime = lastUpdate.UpdateTimestamp } return loopIn, nil diff --git a/staticaddr/loopin/sql_store_test.go b/staticaddr/loopin/sql_store_test.go index 356049bc..1e30081d 100644 --- a/staticaddr/loopin/sql_store_test.go +++ b/staticaddr/loopin/sql_store_test.go @@ -159,7 +159,7 @@ func TestGetStaticAddressLoopInSwapsByStates(t *testing.T) { // StaticAddressLoopIn swap and associates it with the provided deposits. func TestCreateLoopIn(t *testing.T) { // Set up test context objects. - ctxb := context.Background() + ctx := t.Context() testDb := loopdb.NewTestDB(t) testClock := clock.NewTestClock(time.Now()) 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) - err = depositStore.CreateDeposit(ctxb, d2) + err = depositStore.CreateDeposit(ctx, d2) require.NoError(t, err) d1.SetState(deposit.LoopingIn) d2.SetState(deposit.LoopingIn) - err = depositStore.UpdateDeposit(ctxb, d1) + err = depositStore.UpdateDeposit(ctx, d1) require.NoError(t, err) - err = depositStore.UpdateDeposit(ctxb, d2) + err = depositStore.UpdateDeposit(ctx, d2) require.NoError(t, err) _, clientPubKey := test.CreateKey(1) @@ -232,11 +232,11 @@ func TestCreateLoopIn(t *testing.T) { } swapPending.SetState(SignHtlcTx) - err = swapStore.CreateLoopIn(ctxb, &swapPending) + err = swapStore.CreateLoopIn(ctx, &swapPending) require.NoError(t, err) depositIDs, err := swapStore.DepositIDsForSwapHash( - ctxb, swapHashPending, + ctx, swapHashPending, ) require.NoError(t, err) require.Len(t, depositIDs, 2) @@ -244,7 +244,7 @@ func TestCreateLoopIn(t *testing.T) { require.Contains(t, depositIDs, d2.ID) swapHashes, err := swapStore.SwapHashesForDepositIDs( - ctxb, []deposit.ID{depositIDs[0], depositIDs[1]}, + ctx, []deposit.ID{depositIDs[0], depositIDs[1]}, ) require.NoError(t, err) 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[1]) - swap, err := swapStore.GetLoopInByHash(ctxb, swapHashPending) + swap, err := swapStore.GetLoopInByHash(ctx, swapHashPending) require.NoError(t, err) require.Equal(t, swapHashPending, swap.SwapHash) 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.Value, swap.Deposits[1].Value) 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, + ) }