loop/liquidity/static_loopin_test.go
Boris Nagaev 7fcc508193
liquidity: gate static autoloop
Static-address loop-ins in autoloop are still experimental. Reject
loop_in_source=static-address at the RPC boundary unless loopd was started
with --experimental, and pass the same opt-in into the liquidity manager so
persisted params cannot bypass the gate after restart.

The existing static swap accounting remains wired through the manager; the
gate only controls accepting and planning new static-address autoloops.
2026-05-22 02:32:15 -05:00

477 lines
13 KiB
Go

package liquidity
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"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])
}
// TestSuggestSwapsStaticLoopInNoCandidate verifies that the planner surfaces a
// structured disqualification reason when static selection cannot build a
// full-deposit candidate for a peer rule.
func TestSuggestSwapsStaticLoopInNoCandidate(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(context.Context, route.Vertex,
btcutil.Amount, btcutil.Amount, string, string,
[]string) (*PreparedStaticLoopIn, error) {
return nil, ErrNoStaticLoopInCandidate
}
lnd.Channels = []lndclient.ChannelInfo{
{
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 1_000,
RemoteBalance: 9_000,
Capacity: 10_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.AutoloopBudgetLastRefresh = testBudgetStart
params.LoopInSource = LoopInSourceStaticAddress
params.PeerRules = map[route.Vertex]*SwapRule{
peer1: {
ThresholdRule: NewThresholdRule(0, 50),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
suggestions, err := manager.SuggestSwaps(ctx)
require.NoError(t, err)
require.Empty(t, suggestions.StaticInSwaps)
require.Equal(
t, ReasonStaticLoopInNoCandidate,
suggestions.DisqualifiedPeers[peer1],
)
}
// TestSuggestSwapsMixedInFlightCount verifies that static loop-ins consume the
// same accepted-suggestion slots as legacy swaps during final filtering.
func TestSuggestSwapsMixedInFlightCount(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
_, _ btcutil.Amount, label, initiator string,
_ []string) (*PreparedStaticLoopIn, error) {
return &PreparedStaticLoopIn{
Request: loop.StaticAddressLoopInRequest{
DepositOutpoints: []string{"static:0"},
SelectedAmount: 4_000,
MaxSwapFee: 20,
LastHop: &peer,
Label: label,
Initiator: initiator,
},
NumDeposits: 1,
}, nil
}
lnd.Channels = []lndclient.ChannelInfo{
channel1,
{
ChannelID: lnwire.NewShortChanIDFromInt(20).ToUint64(),
PubKeyBytes: peer2,
LocalBalance: 1_000,
RemoteBalance: 9_000,
Capacity: 10_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.AutoloopBudgetLastRefresh = testBudgetStart
params.MaxAutoInFlight = 1
params.FeeLimit = NewFeePortion(500000)
params.LoopInSource = LoopInSourceStaticAddress
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
}
params.PeerRules = map[route.Vertex]*SwapRule{
peer2: {
ThresholdRule: NewThresholdRule(0, 50),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
suggestions, err := manager.SuggestSwaps(ctx)
require.NoError(t, err)
require.Len(t, suggestions.OutSwaps, 1)
require.Empty(t, suggestions.StaticInSwaps)
require.Equal(t, ReasonInFlight, suggestions.DisqualifiedPeers[peer2])
}
// TestAutoLoopDispatchesStaticLoopIn verifies that the autoloop execution path
// dispatches prepared static loop-ins once they survive final filtering.
func TestAutoLoopDispatchesStaticLoopIn(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
var (
prepareCalls int
dispatched *loop.StaticAddressLoopInRequest
prepareInitiator string
)
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
minAmount, amount btcutil.Amount, label, initiator string,
excludedOutpoints []string) (*PreparedStaticLoopIn, error) {
prepareCalls++
prepareInitiator = initiator
require.Equal(t, peer1, peer)
require.Equal(t, testRestrictions.Minimum, minAmount)
require.Equal(t, testRestrictions.Maximum, amount)
require.Empty(t, excludedOutpoints)
return &PreparedStaticLoopIn{
Request: loop.StaticAddressLoopInRequest{
DepositOutpoints: []string{"static:0"},
SelectedAmount: testRestrictions.Maximum,
MaxSwapFee: 100,
LastHop: &peer,
Label: label,
Initiator: initiator,
},
NumDeposits: 1,
}, nil
}
cfg.StaticLoopIn = func(_ context.Context,
request *loop.StaticAddressLoopInRequest) (
*StaticLoopInDispatchResult, error) {
requestCopy := *request
dispatched = &requestCopy
return &StaticLoopInDispatchResult{
SwapHash: lntypes.Hash{1},
}, nil
}
lnd.Channels = []lndclient.ChannelInfo{
{
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 0,
RemoteBalance: 100_000,
Capacity: 100_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.Autoloop = true
params.AutoFeeBudget = 100_000
params.AutoFeeRefreshPeriod = testBudgetRefresh
params.AutoloopBudgetLastRefresh = testBudgetStart
params.MaxAutoInFlight = 1
params.FailureBackOff = time.Hour
params.FeeLimit = NewFeePortion(500_000)
params.LoopInSource = LoopInSourceStaticAddress
params.PeerRules = map[route.Vertex]*SwapRule{
peer1: {
ThresholdRule: NewThresholdRule(0, 60),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
err := manager.autoloop(ctx)
require.NoError(t, err)
require.Equal(t, 1, prepareCalls)
require.Equal(t, autoloopSwapInitiator, prepareInitiator)
require.NotNil(t, dispatched)
require.Equal(t, []string{"static:0"}, dispatched.DepositOutpoints)
require.Equal(t, testRestrictions.Maximum, dispatched.SelectedAmount)
require.Equal(
t, labels.AutoloopLabel(swap.TypeIn), dispatched.Label,
)
require.Equal(t, autoloopSwapInitiator, dispatched.Initiator)
require.NotNil(t, dispatched.LastHop)
require.Equal(t, peer1, *dispatched.LastHop)
}