mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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.
477 lines
13 KiB
Go
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)
|
|
}
|