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.
This commit is contained in:
Boris Nagaev 2026-05-21 15:28:02 -05:00
parent 8d6df2bf20
commit 7fcc508193
No known key found for this signature in database
11 changed files with 88 additions and 6 deletions

View file

@ -343,7 +343,8 @@ var setParamsCommand = &cli.Command{
&cli.StringFlag{ &cli.StringFlag{
Name: "loopinsource", Name: "loopinsource",
Usage: "the loop-in source to use for autoloop rules: " + Usage: "the loop-in source to use for autoloop rules: " +
"wallet or static-address.", "wallet or static-address. Static-address " +
"requires loopd --experimental.",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "easyautoloop", Name: "easyautoloop",

View file

@ -360,7 +360,7 @@ update the parameters set for the liquidity manager
\fB--localbalancesat\fP="": the target size of total local balance in satoshis, used by easy autoloop. (default: 0) \fB--localbalancesat\fP="": the target size of total local balance in satoshis, used by easy autoloop. (default: 0)
.PP .PP
\fB--loopinsource\fP="": the loop-in source to use for autoloop rules: wallet or static-address. \fB--loopinsource\fP="": the loop-in source to use for autoloop rules: wallet or static-address. Static-address requires loopd --experimental.
.PP .PP
\fB--maxamt\fP="": the maximum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0) \fB--maxamt\fP="": the maximum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0)

View file

@ -371,7 +371,7 @@ The following flags are supported:
| `--minamt="…"` | the minimum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` | | `--minamt="…"` | the minimum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` |
| `--maxamt="…"` | the maximum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` | | `--maxamt="…"` | the maximum amount in satoshis that the autoloop client will dispatch per-swap | uint | `0` |
| `--htlc_conf="…"` | the confirmation target for loop in on-chain htlcs | int | `0` | | `--htlc_conf="…"` | the confirmation target for loop in on-chain htlcs | int | `0` |
| `--loopinsource="…"` | the loop-in source to use for autoloop rules: wallet or static-address | string | | `--loopinsource="…"` | the loop-in source to use for autoloop rules: wallet or static-address. Static-address requires loopd --experimental | string |
| `--easyautoloop` | set to true to enable easy autoloop, which will automatically dispatch swaps in order to meet the target local balance | bool | `false` | | `--easyautoloop` | set to true to enable easy autoloop, which will automatically dispatch swaps in order to meet the target local balance | bool | `false` |
| `--localbalancesat="…"` | the target size of total local balance in satoshis, used by easy autoloop | uint | `0` | | `--localbalancesat="…"` | the target size of total local balance in satoshis, used by easy autoloop | uint | `0` |
| `--easyautoloop_excludepeer="…"` | list of peer pubkeys (hex) to exclude from easy autoloop channel selection; repeat --easyautoloop_excludepeer for multiple peers | string | `[]` | | `--easyautoloop_excludepeer="…"` | list of peer pubkeys (hex) to exclude from easy autoloop channel selection; repeat --easyautoloop_excludepeer for multiple peers | string | `[]` |

View file

@ -168,6 +168,11 @@ var (
// account address type is not or vice versa. // account address type is not or vice versa.
ErrAccountAndAddrType = errors.New("account and address type have " + ErrAccountAndAddrType = errors.New("account and address type have " +
"to be both either set or unset") "to be both either set or unset")
// ErrStaticAddressAutoloopExperimental is returned when static-address
// autoloop is configured without the daemon experimental flag.
ErrStaticAddressAutoloopExperimental = errors.New("static address " +
"autoloop requires --experimental")
) )
// Config contains the external functionality required to run the // Config contains the external functionality required to run the
@ -226,6 +231,10 @@ type Config struct {
request *loop.StaticAddressLoopInRequest) ( request *loop.StaticAddressLoopInRequest) (
*StaticLoopInDispatchResult, error) *StaticLoopInDispatchResult, error)
// EnableStaticAddressAutoloop allows the liquidity manager to accept
// static-address loop-in sources for new autoloop parameters.
EnableStaticAddressAutoloop bool
// LoopInTerms returns the terms for a loop in swap. // LoopInTerms returns the terms for a loop in swap.
LoopInTerms func(ctx context.Context, LoopInTerms func(ctx context.Context,
initiator string) (*loop.LoopInTerms, error) initiator string) (*loop.LoopInTerms, error)
@ -392,6 +401,15 @@ func (m *Manager) SetParameters(ctx context.Context,
func (m *Manager) setParameters(ctx context.Context, func (m *Manager) setParameters(ctx context.Context,
params Parameters) error { params Parameters) error {
// Static-address autoloop is still experimental. Check this before
// any network-dependent validation so persisted params cannot start
// the feature after a restart without the daemon opt-in.
if params.LoopInSource == LoopInSourceStaticAddress &&
!m.cfg.EnableStaticAddressAutoloop {
return ErrStaticAddressAutoloopExperimental
}
restrictions, err := m.cfg.Restrictions( restrictions, err := m.cfg.Restrictions(
ctx, swap.TypeOut, getInitiator(m.params), ctx, swap.TypeOut, getInitiator(m.params),
) )

View file

@ -387,6 +387,7 @@ func TestPersistParams(t *testing.T) {
LoopInSource: clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, LoopInSource: clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
} }
cfg, _ := newTestConfig() cfg, _ := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
manager := NewManager(cfg) manager := NewManager(cfg)
ctx := t.Context() ctx := t.Context()
@ -440,6 +441,26 @@ func TestPersistParams(t *testing.T) {
) )
} }
// TestStaticAddressAutoloopRequiresExperimental verifies that static-address
// loop-in sources are rejected unless the daemon enabled the experimental
// static autoloop path.
func TestStaticAddressAutoloopRequiresExperimental(t *testing.T) {
ctx := t.Context()
cfg, _ := newTestConfig()
manager := NewManager(cfg)
params := manager.GetParameters()
params.LoopInSource = LoopInSourceStaticAddress
err := manager.setParameters(ctx, params)
require.ErrorIs(t, err, ErrStaticAddressAutoloopExperimental)
cfg.EnableStaticAddressAutoloop = true
err = manager.setParameters(ctx, params)
require.NoError(t, err)
}
// TestRestrictedSuggestions tests getting of swap suggestions when we have // TestRestrictedSuggestions tests getting of swap suggestions when we have
// other in-flight swaps. We setup our manager with a set of channels and rules // other in-flight swaps. We setup our manager with a set of channels and rules
// that require a loop out swap, focusing on the filtering our of channels that // that require a loop out swap, focusing on the filtering our of channels that

View file

@ -286,6 +286,7 @@ func TestSuggestSwapsStaticLoopInNoCandidate(t *testing.T) {
ctx := t.Context() ctx := t.Context()
cfg, lnd := newTestConfig() cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(context.Context, route.Vertex, cfg.PrepareStaticLoopIn = func(context.Context, route.Vertex,
btcutil.Amount, btcutil.Amount, string, string, btcutil.Amount, btcutil.Amount, string, string,
[]string) (*PreparedStaticLoopIn, error) { []string) (*PreparedStaticLoopIn, error) {
@ -330,6 +331,7 @@ func TestSuggestSwapsMixedInFlightCount(t *testing.T) {
ctx := t.Context() ctx := t.Context()
cfg, lnd := newTestConfig() cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex, cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
_, _ btcutil.Amount, label, initiator string, _, _ btcutil.Amount, label, initiator string,
_ []string) (*PreparedStaticLoopIn, error) { _ []string) (*PreparedStaticLoopIn, error) {
@ -388,6 +390,7 @@ func TestAutoLoopDispatchesStaticLoopIn(t *testing.T) {
ctx := t.Context() ctx := t.Context()
cfg, lnd := newTestConfig() cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
var ( var (
prepareCalls int prepareCalls int

View file

@ -192,7 +192,7 @@ type Config struct {
MaxStaticAddrHtlcFeePercentage float64 `long:"maxstaticaddrhtlcfeepercentage" description:"The maximum fee percentage that the server can charge for the htlc tx."` MaxStaticAddrHtlcFeePercentage float64 `long:"maxstaticaddrhtlcfeepercentage" description:"The maximum fee percentage that the server can charge for the htlc tx."`
MaxStaticAddrHtlcBackupFeePercentage float64 `long:"maxstaticaddrhtlcbackupfeepercentage" description:"The maximum fee percentage that the server can charge for the htlc backup tx. The backup transaction is only used in rare cases when the regular htlc tx is not confirmed on time. These backup transactions refer to high fee or extremely high fee transactions in the API."` MaxStaticAddrHtlcBackupFeePercentage float64 `long:"maxstaticaddrhtlcbackupfeepercentage" description:"The maximum fee percentage that the server can charge for the htlc backup tx. The backup transaction is only used in rare cases when the regular htlc tx is not confirmed on time. These backup transactions refer to high fee or extremely high fee transactions in the API."`
EnableExperimental bool `long:"experimental" description:"Enable experimental features: reservations"` EnableExperimental bool `long:"experimental" description:"Enable experimental features: reservations, static autoloop"`
MigrationRPCBatchSize int `long:"migrationrpcbatchsize" description:"The RPC batch size to use during migrations."` MigrationRPCBatchSize int `long:"migrationrpcbatchsize" description:"The RPC batch size to use during migrations."`

View file

@ -735,7 +735,7 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
} }
liquidityMgr := getLiquidityManager( liquidityMgr := getLiquidityManager(
swapClient, staticLoopInManager, swapClient, staticLoopInManager, d.cfg.EnableExperimental,
) )
// Now finally fully initialize the swap client RPC server instance. // Now finally fully initialize the swap client RPC server instance.

View file

@ -1395,6 +1395,18 @@ func (s *swapClientServer) SetLiquidityParams(ctx context.Context,
in *looprpc.SetLiquidityParamsRequest) (*looprpc.SetLiquidityParamsResponse, in *looprpc.SetLiquidityParamsRequest) (*looprpc.SetLiquidityParamsResponse,
error) { error) {
enableExperimental := s.config != nil && s.config.EnableExperimental
params := in.GetParameters()
if params.GetLoopInSource() ==
looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS &&
!enableExperimental {
return nil, status.Error(
codes.FailedPrecondition,
liquidity.ErrStaticAddressAutoloopExperimental.Error(),
)
}
err := s.liquidityMgr.SetParameters(ctx, in.Parameters) err := s.liquidityMgr.SetParameters(ctx, in.Parameters)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -28,6 +28,8 @@ import (
"github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
var ( var (
@ -281,6 +283,29 @@ func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) {
require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) require.ErrorContains(t, err, labels.ErrReservedPrefix.Error())
} }
// TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental verifies that
// users must restart loopd with --experimental before enabling static-address
// autoloop.
func TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental(
t *testing.T) {
server := &swapClientServer{
config: &Config{},
}
_, err := server.SetLiquidityParams(
t.Context(), &looprpc.SetLiquidityParamsRequest{
Parameters: &looprpc.LiquidityParameters{
LoopInSource: looprpc.
LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
},
},
)
require.Error(t, err)
require.Equal(t, codes.FailedPrecondition, status.Code(err))
require.ErrorContains(t, err, "--experimental")
}
// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner // TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner
// reason is exposed over rpc. // reason is exposed over rpc.
func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) { func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) {

View file

@ -120,7 +120,8 @@ func openDatabase(cfg *Config, chainParams *chaincfg.Params) (loopdb.SwapStore,
} }
func getLiquidityManager(client *loop.Client, func getLiquidityManager(client *loop.Client,
staticLoopInManager *loopin.Manager) *liquidity.Manager { staticLoopInManager *loopin.Manager,
enableExperimental bool) *liquidity.Manager {
listStaticLoopIn := func( listStaticLoopIn := func(
ctx context.Context) ([]*liquidity.StaticLoopInInfo, error) { ctx context.Context) ([]*liquidity.StaticLoopInInfo, error) {
@ -259,6 +260,7 @@ func getLiquidityManager(client *loop.Client,
PutLiquidityParams: client.Store.PutLiquidityParams, PutLiquidityParams: client.Store.PutLiquidityParams,
FetchLiquidityParams: client.Store.FetchLiquidityParams, FetchLiquidityParams: client.Store.FetchLiquidityParams,
} }
mngrCfg.EnableStaticAddressAutoloop = enableExperimental
return liquidity.NewManager(mngrCfg) return liquidity.NewManager(mngrCfg)
} }