From 26db134d4555110df85fe59cbcb4a91e7fe311de Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:41:52 -0500 Subject: [PATCH 01/10] looprpc: add autoloop loop-in source Add a dedicated loop-in source enum to the liquidity parameters rpc and wire it through the internal parameter model and CLI. This keeps the source selection explicit before any static autoloop planning lands, so operators can choose between the legacy wallet-funded path and a future static-address-backed path without relying on implicit fallback behavior. --- cmd/loop/liquidity.go | 24 ++ liquidity/liquidity_test.go | 16 +- liquidity/loopin_source.go | 30 +++ liquidity/parameters.go | 61 ++++- looprpc/client.pb.go | 511 ++++++++++++++++++++---------------- looprpc/client.proto | 17 ++ looprpc/client.swagger.json | 13 + 7 files changed, 441 insertions(+), 231 deletions(-) create mode 100644 liquidity/loopin_source.go diff --git a/cmd/loop/liquidity.go b/cmd/loop/liquidity.go index 5492f2045..8a9b17a39 100644 --- a/cmd/loop/liquidity.go +++ b/cmd/loop/liquidity.go @@ -340,6 +340,11 @@ var setParamsCommand = &cli.Command{ Usage: "the confirmation target for loop in on-chain " + "htlcs.", }, + &cli.StringFlag{ + Name: "loopinsource", + Usage: "the loop-in source to use for autoloop rules: " + + "wallet or static-address.", + }, &cli.BoolFlag{ Name: "easyautoloop", Usage: "set to true to enable easy autoloop, which " + @@ -555,6 +560,25 @@ func setParams(ctx context.Context, cmd *cli.Command) error { flagSet = true } + if cmd.IsSet("loopinsource") { + switch cmd.String("loopinsource") { + case "wallet": + params.LoopInSource = + looprpc.LoopInSource_LOOP_IN_SOURCE_WALLET + + case "static-address", "static_address", "static": + params.LoopInSource = + looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS + + default: + return fmt.Errorf("unknown loopinsource value %q "+ + "(use \"wallet\" or \"static-address\")", + cmd.String("loopinsource")) + } + + flagSet = true + } + // If we are setting easy autoloop parameters, we need to ensure that // the asset ID is set, and that we have a valid entry in our params // map. diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index 00f2cb58f..3824ce9ce 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -262,11 +262,12 @@ func TestPersistParams(t *testing.T) { FeePpm: 100, AutoMaxInFlight: 10, HtlcConfTarget: 2, + LoopInSource: clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, } cfg, _ := newTestConfig() manager := NewManager(cfg) - ctxb := context.Background() + ctx := t.Context() var paramsBytes []byte @@ -276,7 +277,7 @@ func TestPersistParams(t *testing.T) { } // Test the nil params is returned. - req, err := manager.loadParams(ctxb) + req, err := manager.loadParams(ctx) require.Nil(t, req) require.NoError(t, err) @@ -289,17 +290,18 @@ func TestPersistParams(t *testing.T) { } // Test save the message. - err = manager.saveParams(ctxb, rpcParams) + err = manager.saveParams(ctx, rpcParams) require.NoError(t, err) // Test the nil params is returned. - req, err = manager.loadParams(ctxb) + req, err = manager.loadParams(ctx) require.NoError(t, err) // Check the specified fields are set as expected. require.Equal(t, rpcParams.FeePpm, req.FeePpm) require.Equal(t, rpcParams.AutoMaxInFlight, req.AutoMaxInFlight) require.Equal(t, rpcParams.HtlcConfTarget, req.HtlcConfTarget) + require.Equal(t, rpcParams.LoopInSource, req.LoopInSource) // Check the unspecified fields are using empty values. require.False(t, req.Autoloop) @@ -308,8 +310,12 @@ func TestPersistParams(t *testing.T) { // Finally, check the loaded request can be used to set params without // error. - err = manager.SetParameters(context.Background(), req) + err = manager.SetParameters(ctx, req) require.NoError(t, err) + require.Equal( + t, LoopInSourceStaticAddress, + manager.GetParameters().LoopInSource, + ) } // TestRestrictedSuggestions tests getting of swap suggestions when we have diff --git a/liquidity/loopin_source.go b/liquidity/loopin_source.go new file mode 100644 index 000000000..14f698d60 --- /dev/null +++ b/liquidity/loopin_source.go @@ -0,0 +1,30 @@ +package liquidity + +import "fmt" + +// LoopInSource identifies the funding source that autoloop should use for loop +// in swaps. +type LoopInSource uint8 + +const ( + // LoopInSourceWallet uses the legacy wallet-funded loop-in flow. + LoopInSourceWallet LoopInSource = iota + + // LoopInSourceStaticAddress uses deposited static-address funds for + // loop-ins and does not fall back to wallet-funded loop-ins. + LoopInSourceStaticAddress +) + +// String returns a human-readable representation of the source. +func (s LoopInSource) String() string { + switch s { + case LoopInSourceWallet: + return "wallet" + + case LoopInSourceStaticAddress: + return "static-address" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} diff --git a/liquidity/parameters.go b/liquidity/parameters.go index 7032ff83a..44c555db6 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -32,6 +32,7 @@ var ( HtlcConfTarget: defaultHtlcConfTarget, FeeLimit: defaultFeePortion(), FastSwapPublication: true, + LoopInSource: LoopInSourceWallet, } ) @@ -128,6 +129,10 @@ type Parameters struct { // swaps. If set to true, the deadline is set to immediate publication. // If set to false, the deadline is set to 30 minutes. FastSwapPublication bool + + // LoopInSource controls which funding source autoloop uses for loop-in + // rules. + LoopInSource LoopInSource } // AssetParams define the asset specific autoloop parameters. @@ -160,11 +165,13 @@ func (p Parameters) String() string { return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ "sweep conf target: %v, htlc conf target: %v,fees: %v, "+ "auto budget: %v, budget refresh: %v, max auto in flight: %v, "+ - "minimum swap size=%v, maximum swap size=%v", + "minimum swap size: %v, maximum swap size: %v, "+ + "loop in source: %v", strings.Join(ruleList, ","), p.FailureBackOff, p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit, p.AutoFeeBudget, p.AutoFeeRefreshPeriod, p.MaxAutoInFlight, - p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) + p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum, + p.LoopInSource) } // haveRules returns a boolean indicating whether we have any rules configured. @@ -260,6 +267,13 @@ func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, return ErrZeroInFlight } + switch p.LoopInSource { + case LoopInSourceWallet, LoopInSourceStaticAddress: + + default: + return fmt.Errorf("unknown loop in source: %v", p.LoopInSource) + } + // Destination address and account cannot be set at the same time. if p.DestAddr != nil && len(p.DestAddr.String()) > 0 && len(p.Account) > 0 { @@ -409,6 +423,37 @@ func rpcToRule(rule *clientrpc.LiquidityRule) (*SwapRule, error) { } } +// rpcToLoopInSource converts the rpc loop-in source enum to the internal +// liquidity enum. +func rpcToLoopInSource(source clientrpc.LoopInSource) (LoopInSource, error) { + switch source { + case clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET: + return LoopInSourceWallet, nil + + case clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS: + return LoopInSourceStaticAddress, nil + + default: + return 0, fmt.Errorf("unknown rpc loop in source: %v", source) + } +} + +// loopInSourceToRPC converts the internal loop-in source enum to its rpc +// representation. +func loopInSourceToRPC(source LoopInSource) (clientrpc.LoopInSource, error) { + switch source { + case LoopInSourceWallet: + return clientrpc.LoopInSource_LOOP_IN_SOURCE_WALLET, nil + + case LoopInSourceStaticAddress: + return clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS, + nil + + default: + return 0, fmt.Errorf("unknown loop in source: %v", source) + } +} + // RpcToParameters takes a `LiquidityParameters` and creates a `Parameters` // from it. func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, @@ -446,6 +491,11 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, } } + loopInSource, err := rpcToLoopInSource(req.LoopInSource) + if err != nil { + return nil, err + } + params := &Parameters{ FeeLimit: feeLimit, SweepConfTarget: req.SweepConfTarget, @@ -477,6 +527,7 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, ), AssetAutoloopParams: easyAssetParams, FastSwapPublication: req.FastSwapPublication, + LoopInSource: loopInSource, } if req.AutoloopBudgetRefreshPeriodSec != 0 { @@ -592,6 +643,11 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, } } + loopInSource, err := loopInSourceToRPC(cfg.LoopInSource) + if err != nil { + return nil, err + } + rpcCfg := &clientrpc.LiquidityParameters{ SweepConfTarget: cfg.SweepConfTarget, FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()), @@ -621,6 +677,7 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, AccountAddrType: addrType, EasyAssetParams: easyAssetMap, FastSwapPublication: cfg.FastSwapPublication, + LoopInSource: loopInSource, } // Set excluded peers for easy autoloop. rpcCfg.EasyAutoloopExcludedPeers = make( diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 35ae71c82..87bf5bb1d 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -288,6 +288,54 @@ func (FailureReason) EnumDescriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{3} } +type LoopInSource int32 + +const ( + // Use the legacy wallet-funded loop-in flow. + LoopInSource_LOOP_IN_SOURCE_WALLET LoopInSource = 0 + // Use deposited static-address funds for loop-in autoloops. + LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS LoopInSource = 1 +) + +// Enum value maps for LoopInSource. +var ( + LoopInSource_name = map[int32]string{ + 0: "LOOP_IN_SOURCE_WALLET", + 1: "LOOP_IN_SOURCE_STATIC_ADDRESS", + } + LoopInSource_value = map[string]int32{ + "LOOP_IN_SOURCE_WALLET": 0, + "LOOP_IN_SOURCE_STATIC_ADDRESS": 1, + } +) + +func (x LoopInSource) Enum() *LoopInSource { + p := new(LoopInSource) + *p = x + return p +} + +func (x LoopInSource) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (LoopInSource) Descriptor() protoreflect.EnumDescriptor { + return file_client_proto_enumTypes[4].Descriptor() +} + +func (LoopInSource) Type() protoreflect.EnumType { + return &file_client_proto_enumTypes[4] +} + +func (x LoopInSource) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use LoopInSource.Descriptor instead. +func (LoopInSource) EnumDescriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{4} +} + type LiquidityRuleType int32 const ( @@ -318,11 +366,11 @@ func (x LiquidityRuleType) String() string { } func (LiquidityRuleType) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[4].Descriptor() + return file_client_proto_enumTypes[5].Descriptor() } func (LiquidityRuleType) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[4] + return &file_client_proto_enumTypes[5] } func (x LiquidityRuleType) Number() protoreflect.EnumNumber { @@ -331,7 +379,7 @@ func (x LiquidityRuleType) Number() protoreflect.EnumNumber { // Deprecated: Use LiquidityRuleType.Descriptor instead. func (LiquidityRuleType) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{4} + return file_client_proto_rawDescGZIP(), []int{5} } type AutoReason int32 @@ -425,11 +473,11 @@ func (x AutoReason) String() string { } func (AutoReason) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[5].Descriptor() + return file_client_proto_enumTypes[6].Descriptor() } func (AutoReason) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[5] + return &file_client_proto_enumTypes[6] } func (x AutoReason) Number() protoreflect.EnumNumber { @@ -438,7 +486,7 @@ func (x AutoReason) Number() protoreflect.EnumNumber { // Deprecated: Use AutoReason.Descriptor instead. func (AutoReason) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{5} + return file_client_proto_rawDescGZIP(), []int{6} } type DepositState int32 @@ -530,11 +578,11 @@ func (x DepositState) String() string { } func (DepositState) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[6].Descriptor() + return file_client_proto_enumTypes[7].Descriptor() } func (DepositState) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[6] + return &file_client_proto_enumTypes[7] } func (x DepositState) Number() protoreflect.EnumNumber { @@ -543,7 +591,7 @@ func (x DepositState) Number() protoreflect.EnumNumber { // Deprecated: Use DepositState.Descriptor instead. func (DepositState) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{6} + return file_client_proto_rawDescGZIP(), []int{7} } type StaticAddressLoopInSwapState int32 @@ -606,11 +654,11 @@ func (x StaticAddressLoopInSwapState) String() string { } func (StaticAddressLoopInSwapState) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[7].Descriptor() + return file_client_proto_enumTypes[8].Descriptor() } func (StaticAddressLoopInSwapState) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[7] + return &file_client_proto_enumTypes[8] } func (x StaticAddressLoopInSwapState) Number() protoreflect.EnumNumber { @@ -619,7 +667,7 @@ func (x StaticAddressLoopInSwapState) Number() protoreflect.EnumNumber { // Deprecated: Use StaticAddressLoopInSwapState.Descriptor instead. func (StaticAddressLoopInSwapState) EnumDescriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{7} + return file_client_proto_rawDescGZIP(), []int{8} } type ListSwapsFilter_SwapTypeFilter int32 @@ -658,11 +706,11 @@ func (x ListSwapsFilter_SwapTypeFilter) String() string { } func (ListSwapsFilter_SwapTypeFilter) Descriptor() protoreflect.EnumDescriptor { - return file_client_proto_enumTypes[8].Descriptor() + return file_client_proto_enumTypes[9].Descriptor() } func (ListSwapsFilter_SwapTypeFilter) Type() protoreflect.EnumType { - return &file_client_proto_enumTypes[8] + return &file_client_proto_enumTypes[9] } func (x ListSwapsFilter_SwapTypeFilter) Number() protoreflect.EnumNumber { @@ -3455,8 +3503,10 @@ type LiquidityParameters struct { // autoloop run. If set, channels connected to these peers won't be // considered for easy autoloop swaps. EasyAutoloopExcludedPeers [][]byte `protobuf:"bytes,27,rep,name=easy_autoloop_excluded_peers,json=easyAutoloopExcludedPeers,proto3" json:"easy_autoloop_excluded_peers,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Selects which source autoloop uses for loop-in rules. + LoopInSource LoopInSource `protobuf:"varint,28,opt,name=loop_in_source,json=loopInSource,proto3,enum=looprpc.LoopInSource" json:"loop_in_source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LiquidityParameters) Reset() { @@ -3679,6 +3729,13 @@ func (x *LiquidityParameters) GetEasyAutoloopExcludedPeers() [][]byte { return nil } +func (x *LiquidityParameters) GetLoopInSource() LoopInSource { + if x != nil { + return x.LoopInSource + } + return LoopInSource_LOOP_IN_SOURCE_WALLET +} + type EasyAssetAutoloopParams struct { state protoimpl.MessageState `protogen:"open.v1"` // Set to true to enable easy autoloop for this asset. If set the client will @@ -6732,7 +6789,7 @@ const file_client_proto_rawDesc = "" + "\rloop_in_stats\x18\b \x01(\v2\x12.looprpc.LoopStatsR\vloopInStats\x12\x1f\n" + "\vcommit_hash\x18\t \x01(\tR\n" + "commitHash\"\x1b\n" + - "\x19GetLiquidityParamsRequest\"\xce\v\n" + + "\x19GetLiquidityParamsRequest\"\x8b\f\n" + "\x13LiquidityParameters\x12,\n" + "\x05rules\x18\x01 \x03(\v2\x16.looprpc.LiquidityRuleR\x05rules\x12\x17\n" + "\afee_ppm\x18\x10 \x01(\x04R\x06feePpm\x12=\n" + @@ -6761,7 +6818,8 @@ const file_client_proto_rawDesc = "" + "\x11account_addr_type\x18\x18 \x01(\x0e2\x14.looprpc.AddressTypeR\x0faccountAddrType\x12]\n" + "\x11easy_asset_params\x18\x19 \x03(\v21.looprpc.LiquidityParameters.EasyAssetParamsEntryR\x0feasyAssetParams\x122\n" + "\x15fast_swap_publication\x18\x1a \x01(\bR\x13fastSwapPublication\x12?\n" + - "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x1ad\n" + + "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x12;\n" + + "\x0eloop_in_source\x18\x1c \x01(\x0e2\x15.looprpc.LoopInSourceR\floopInSource\x1ad\n" + "\x14EasyAssetParamsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x126\n" + "\x05value\x18\x02 \x01(\v2 .looprpc.EasyAssetAutoloopParamsR\x05value:\x028\x01\"h\n" + @@ -6980,7 +7038,10 @@ const file_client_proto_rawDesc = "" + "\x1fFAILURE_REASON_INCORRECT_AMOUNT\x10\x06\x12\x1c\n" + "\x18FAILURE_REASON_ABANDONED\x10\a\x121\n" + "-FAILURE_REASON_INSUFFICIENT_CONFIRMED_BALANCE\x10\b\x12+\n" + - "'FAILURE_REASON_INCORRECT_HTLC_AMT_SWEPT\x10\t*/\n" + + "'FAILURE_REASON_INCORRECT_HTLC_AMT_SWEPT\x10\t*L\n" + + "\fLoopInSource\x12\x19\n" + + "\x15LOOP_IN_SOURCE_WALLET\x10\x00\x12!\n" + + "\x1dLOOP_IN_SOURCE_STATIC_ADDRESS\x10\x01*/\n" + "\x11LiquidityRuleType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\r\n" + "\tTHRESHOLD\x10\x01*\xa6\x03\n" + @@ -7081,223 +7142,225 @@ func file_client_proto_rawDescGZIP() []byte { return file_client_proto_rawDescData } -var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 9) +var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 80) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType (SwapState)(0), // 2: looprpc.SwapState (FailureReason)(0), // 3: looprpc.FailureReason - (LiquidityRuleType)(0), // 4: looprpc.LiquidityRuleType - (AutoReason)(0), // 5: looprpc.AutoReason - (DepositState)(0), // 6: looprpc.DepositState - (StaticAddressLoopInSwapState)(0), // 7: looprpc.StaticAddressLoopInSwapState - (ListSwapsFilter_SwapTypeFilter)(0), // 8: looprpc.ListSwapsFilter.SwapTypeFilter - (*StaticOpenChannelRequest)(nil), // 9: looprpc.StaticOpenChannelRequest - (*StaticOpenChannelResponse)(nil), // 10: looprpc.StaticOpenChannelResponse - (*StopDaemonRequest)(nil), // 11: looprpc.StopDaemonRequest - (*StopDaemonResponse)(nil), // 12: looprpc.StopDaemonResponse - (*LoopOutRequest)(nil), // 13: looprpc.LoopOutRequest - (*LoopInRequest)(nil), // 14: looprpc.LoopInRequest - (*SwapResponse)(nil), // 15: looprpc.SwapResponse - (*MonitorRequest)(nil), // 16: looprpc.MonitorRequest - (*SwapStatus)(nil), // 17: looprpc.SwapStatus - (*ListSwapsRequest)(nil), // 18: looprpc.ListSwapsRequest - (*ListSwapsFilter)(nil), // 19: looprpc.ListSwapsFilter - (*ListSwapsResponse)(nil), // 20: looprpc.ListSwapsResponse - (*SweepHtlcRequest)(nil), // 21: looprpc.SweepHtlcRequest - (*SweepHtlcResponse)(nil), // 22: looprpc.SweepHtlcResponse - (*PublishNotRequested)(nil), // 23: looprpc.PublishNotRequested - (*PublishSucceeded)(nil), // 24: looprpc.PublishSucceeded - (*PublishFailed)(nil), // 25: looprpc.PublishFailed - (*SwapInfoRequest)(nil), // 26: looprpc.SwapInfoRequest - (*TermsRequest)(nil), // 27: looprpc.TermsRequest - (*InTermsResponse)(nil), // 28: looprpc.InTermsResponse - (*OutTermsResponse)(nil), // 29: looprpc.OutTermsResponse - (*QuoteRequest)(nil), // 30: looprpc.QuoteRequest - (*InQuoteResponse)(nil), // 31: looprpc.InQuoteResponse - (*OutQuoteResponse)(nil), // 32: looprpc.OutQuoteResponse - (*ProbeRequest)(nil), // 33: looprpc.ProbeRequest - (*ProbeResponse)(nil), // 34: looprpc.ProbeResponse - (*TokensRequest)(nil), // 35: looprpc.TokensRequest - (*TokensResponse)(nil), // 36: looprpc.TokensResponse - (*FetchL402TokenRequest)(nil), // 37: looprpc.FetchL402TokenRequest - (*FetchL402TokenResponse)(nil), // 38: looprpc.FetchL402TokenResponse - (*L402Token)(nil), // 39: looprpc.L402Token - (*LoopStats)(nil), // 40: looprpc.LoopStats - (*GetInfoRequest)(nil), // 41: looprpc.GetInfoRequest - (*GetInfoResponse)(nil), // 42: looprpc.GetInfoResponse - (*GetLiquidityParamsRequest)(nil), // 43: looprpc.GetLiquidityParamsRequest - (*LiquidityParameters)(nil), // 44: looprpc.LiquidityParameters - (*EasyAssetAutoloopParams)(nil), // 45: looprpc.EasyAssetAutoloopParams - (*LiquidityRule)(nil), // 46: looprpc.LiquidityRule - (*SetLiquidityParamsRequest)(nil), // 47: looprpc.SetLiquidityParamsRequest - (*SetLiquidityParamsResponse)(nil), // 48: looprpc.SetLiquidityParamsResponse - (*SuggestSwapsRequest)(nil), // 49: looprpc.SuggestSwapsRequest - (*Disqualified)(nil), // 50: looprpc.Disqualified - (*SuggestSwapsResponse)(nil), // 51: looprpc.SuggestSwapsResponse - (*AbandonSwapRequest)(nil), // 52: looprpc.AbandonSwapRequest - (*AbandonSwapResponse)(nil), // 53: looprpc.AbandonSwapResponse - (*ListReservationsRequest)(nil), // 54: looprpc.ListReservationsRequest - (*ListReservationsResponse)(nil), // 55: looprpc.ListReservationsResponse - (*ClientReservation)(nil), // 56: looprpc.ClientReservation - (*InstantOutRequest)(nil), // 57: looprpc.InstantOutRequest - (*InstantOutResponse)(nil), // 58: looprpc.InstantOutResponse - (*InstantOutQuoteRequest)(nil), // 59: looprpc.InstantOutQuoteRequest - (*InstantOutQuoteResponse)(nil), // 60: looprpc.InstantOutQuoteResponse - (*ListInstantOutsRequest)(nil), // 61: looprpc.ListInstantOutsRequest - (*ListInstantOutsResponse)(nil), // 62: looprpc.ListInstantOutsResponse - (*InstantOut)(nil), // 63: looprpc.InstantOut - (*NewStaticAddressRequest)(nil), // 64: looprpc.NewStaticAddressRequest - (*NewStaticAddressResponse)(nil), // 65: looprpc.NewStaticAddressResponse - (*ListUnspentDepositsRequest)(nil), // 66: looprpc.ListUnspentDepositsRequest - (*ListUnspentDepositsResponse)(nil), // 67: looprpc.ListUnspentDepositsResponse - (*Utxo)(nil), // 68: looprpc.Utxo - (*WithdrawDepositsRequest)(nil), // 69: looprpc.WithdrawDepositsRequest - (*WithdrawDepositsResponse)(nil), // 70: looprpc.WithdrawDepositsResponse - (*ListStaticAddressDepositsRequest)(nil), // 71: looprpc.ListStaticAddressDepositsRequest - (*ListStaticAddressDepositsResponse)(nil), // 72: looprpc.ListStaticAddressDepositsResponse - (*ListStaticAddressWithdrawalRequest)(nil), // 73: looprpc.ListStaticAddressWithdrawalRequest - (*ListStaticAddressWithdrawalResponse)(nil), // 74: looprpc.ListStaticAddressWithdrawalResponse - (*ListStaticAddressSwapsRequest)(nil), // 75: looprpc.ListStaticAddressSwapsRequest - (*ListStaticAddressSwapsResponse)(nil), // 76: looprpc.ListStaticAddressSwapsResponse - (*StaticAddressSummaryRequest)(nil), // 77: looprpc.StaticAddressSummaryRequest - (*StaticAddressSummaryResponse)(nil), // 78: looprpc.StaticAddressSummaryResponse - (*Deposit)(nil), // 79: looprpc.Deposit - (*StaticAddressWithdrawal)(nil), // 80: looprpc.StaticAddressWithdrawal - (*StaticAddressLoopInSwap)(nil), // 81: looprpc.StaticAddressLoopInSwap - (*StaticAddressLoopInRequest)(nil), // 82: looprpc.StaticAddressLoopInRequest - (*StaticAddressLoopInResponse)(nil), // 83: looprpc.StaticAddressLoopInResponse - (*AssetLoopOutRequest)(nil), // 84: looprpc.AssetLoopOutRequest - (*AssetRfqInfo)(nil), // 85: looprpc.AssetRfqInfo - (*FixedPoint)(nil), // 86: looprpc.FixedPoint - (*AssetLoopOutInfo)(nil), // 87: looprpc.AssetLoopOutInfo - nil, // 88: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 89: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 90: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 91: lnrpc.OutPoint + (LoopInSource)(0), // 4: looprpc.LoopInSource + (LiquidityRuleType)(0), // 5: looprpc.LiquidityRuleType + (AutoReason)(0), // 6: looprpc.AutoReason + (DepositState)(0), // 7: looprpc.DepositState + (StaticAddressLoopInSwapState)(0), // 8: looprpc.StaticAddressLoopInSwapState + (ListSwapsFilter_SwapTypeFilter)(0), // 9: looprpc.ListSwapsFilter.SwapTypeFilter + (*StaticOpenChannelRequest)(nil), // 10: looprpc.StaticOpenChannelRequest + (*StaticOpenChannelResponse)(nil), // 11: looprpc.StaticOpenChannelResponse + (*StopDaemonRequest)(nil), // 12: looprpc.StopDaemonRequest + (*StopDaemonResponse)(nil), // 13: looprpc.StopDaemonResponse + (*LoopOutRequest)(nil), // 14: looprpc.LoopOutRequest + (*LoopInRequest)(nil), // 15: looprpc.LoopInRequest + (*SwapResponse)(nil), // 16: looprpc.SwapResponse + (*MonitorRequest)(nil), // 17: looprpc.MonitorRequest + (*SwapStatus)(nil), // 18: looprpc.SwapStatus + (*ListSwapsRequest)(nil), // 19: looprpc.ListSwapsRequest + (*ListSwapsFilter)(nil), // 20: looprpc.ListSwapsFilter + (*ListSwapsResponse)(nil), // 21: looprpc.ListSwapsResponse + (*SweepHtlcRequest)(nil), // 22: looprpc.SweepHtlcRequest + (*SweepHtlcResponse)(nil), // 23: looprpc.SweepHtlcResponse + (*PublishNotRequested)(nil), // 24: looprpc.PublishNotRequested + (*PublishSucceeded)(nil), // 25: looprpc.PublishSucceeded + (*PublishFailed)(nil), // 26: looprpc.PublishFailed + (*SwapInfoRequest)(nil), // 27: looprpc.SwapInfoRequest + (*TermsRequest)(nil), // 28: looprpc.TermsRequest + (*InTermsResponse)(nil), // 29: looprpc.InTermsResponse + (*OutTermsResponse)(nil), // 30: looprpc.OutTermsResponse + (*QuoteRequest)(nil), // 31: looprpc.QuoteRequest + (*InQuoteResponse)(nil), // 32: looprpc.InQuoteResponse + (*OutQuoteResponse)(nil), // 33: looprpc.OutQuoteResponse + (*ProbeRequest)(nil), // 34: looprpc.ProbeRequest + (*ProbeResponse)(nil), // 35: looprpc.ProbeResponse + (*TokensRequest)(nil), // 36: looprpc.TokensRequest + (*TokensResponse)(nil), // 37: looprpc.TokensResponse + (*FetchL402TokenRequest)(nil), // 38: looprpc.FetchL402TokenRequest + (*FetchL402TokenResponse)(nil), // 39: looprpc.FetchL402TokenResponse + (*L402Token)(nil), // 40: looprpc.L402Token + (*LoopStats)(nil), // 41: looprpc.LoopStats + (*GetInfoRequest)(nil), // 42: looprpc.GetInfoRequest + (*GetInfoResponse)(nil), // 43: looprpc.GetInfoResponse + (*GetLiquidityParamsRequest)(nil), // 44: looprpc.GetLiquidityParamsRequest + (*LiquidityParameters)(nil), // 45: looprpc.LiquidityParameters + (*EasyAssetAutoloopParams)(nil), // 46: looprpc.EasyAssetAutoloopParams + (*LiquidityRule)(nil), // 47: looprpc.LiquidityRule + (*SetLiquidityParamsRequest)(nil), // 48: looprpc.SetLiquidityParamsRequest + (*SetLiquidityParamsResponse)(nil), // 49: looprpc.SetLiquidityParamsResponse + (*SuggestSwapsRequest)(nil), // 50: looprpc.SuggestSwapsRequest + (*Disqualified)(nil), // 51: looprpc.Disqualified + (*SuggestSwapsResponse)(nil), // 52: looprpc.SuggestSwapsResponse + (*AbandonSwapRequest)(nil), // 53: looprpc.AbandonSwapRequest + (*AbandonSwapResponse)(nil), // 54: looprpc.AbandonSwapResponse + (*ListReservationsRequest)(nil), // 55: looprpc.ListReservationsRequest + (*ListReservationsResponse)(nil), // 56: looprpc.ListReservationsResponse + (*ClientReservation)(nil), // 57: looprpc.ClientReservation + (*InstantOutRequest)(nil), // 58: looprpc.InstantOutRequest + (*InstantOutResponse)(nil), // 59: looprpc.InstantOutResponse + (*InstantOutQuoteRequest)(nil), // 60: looprpc.InstantOutQuoteRequest + (*InstantOutQuoteResponse)(nil), // 61: looprpc.InstantOutQuoteResponse + (*ListInstantOutsRequest)(nil), // 62: looprpc.ListInstantOutsRequest + (*ListInstantOutsResponse)(nil), // 63: looprpc.ListInstantOutsResponse + (*InstantOut)(nil), // 64: looprpc.InstantOut + (*NewStaticAddressRequest)(nil), // 65: looprpc.NewStaticAddressRequest + (*NewStaticAddressResponse)(nil), // 66: looprpc.NewStaticAddressResponse + (*ListUnspentDepositsRequest)(nil), // 67: looprpc.ListUnspentDepositsRequest + (*ListUnspentDepositsResponse)(nil), // 68: looprpc.ListUnspentDepositsResponse + (*Utxo)(nil), // 69: looprpc.Utxo + (*WithdrawDepositsRequest)(nil), // 70: looprpc.WithdrawDepositsRequest + (*WithdrawDepositsResponse)(nil), // 71: looprpc.WithdrawDepositsResponse + (*ListStaticAddressDepositsRequest)(nil), // 72: looprpc.ListStaticAddressDepositsRequest + (*ListStaticAddressDepositsResponse)(nil), // 73: looprpc.ListStaticAddressDepositsResponse + (*ListStaticAddressWithdrawalRequest)(nil), // 74: looprpc.ListStaticAddressWithdrawalRequest + (*ListStaticAddressWithdrawalResponse)(nil), // 75: looprpc.ListStaticAddressWithdrawalResponse + (*ListStaticAddressSwapsRequest)(nil), // 76: looprpc.ListStaticAddressSwapsRequest + (*ListStaticAddressSwapsResponse)(nil), // 77: looprpc.ListStaticAddressSwapsResponse + (*StaticAddressSummaryRequest)(nil), // 78: looprpc.StaticAddressSummaryRequest + (*StaticAddressSummaryResponse)(nil), // 79: looprpc.StaticAddressSummaryResponse + (*Deposit)(nil), // 80: looprpc.Deposit + (*StaticAddressWithdrawal)(nil), // 81: looprpc.StaticAddressWithdrawal + (*StaticAddressLoopInSwap)(nil), // 82: looprpc.StaticAddressLoopInSwap + (*StaticAddressLoopInRequest)(nil), // 83: looprpc.StaticAddressLoopInRequest + (*StaticAddressLoopInResponse)(nil), // 84: looprpc.StaticAddressLoopInResponse + (*AssetLoopOutRequest)(nil), // 85: looprpc.AssetLoopOutRequest + (*AssetRfqInfo)(nil), // 86: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 87: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo + nil, // 89: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 90: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 91: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 92: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 89, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 90, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType - 84, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 91, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState 3, // 7: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 87, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo - 19, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter - 8, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter - 17, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus - 23, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 24, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 25, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 90, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 84, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 85, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 90, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 39, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 40, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 40, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 46, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 88, // 8: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 20, // 9: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter + 9, // 10: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter + 18, // 11: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus + 24, // 12: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 13: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 14: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 91, // 15: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 16: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 86, // 17: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 91, // 18: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 19: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 20: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 21: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 22: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule 0, // 23: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 88, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 1, // 25: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 4, // 26: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 44, // 27: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 5, // 28: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 13, // 29: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 14, // 30: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 50, // 31: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 56, // 32: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 63, // 33: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 68, // 34: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 91, // 35: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 6, // 36: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 79, // 37: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 80, // 38: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 81, // 39: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 6, // 40: looprpc.Deposit.state:type_name -> looprpc.DepositState - 79, // 41: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 7, // 42: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 79, // 43: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 90, // 44: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 79, // 45: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 86, // 46: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 86, // 47: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 45, // 48: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 13, // 49: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 14, // 50: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 16, // 51: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 18, // 52: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 21, // 53: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 26, // 54: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 52, // 55: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 27, // 56: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 30, // 57: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 27, // 58: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 30, // 59: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 33, // 60: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 35, // 61: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 35, // 62: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 37, // 63: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 41, // 64: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 11, // 65: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 43, // 66: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 47, // 67: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 49, // 68: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 54, // 69: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 57, // 70: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 59, // 71: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 61, // 72: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 64, // 73: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 66, // 74: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 69, // 75: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 71, // 76: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 73, // 77: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 75, // 78: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 77, // 79: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 82, // 80: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 9, // 81: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 15, // 82: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 15, // 83: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 17, // 84: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 20, // 85: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 22, // 86: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 17, // 87: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 53, // 88: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 29, // 89: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 32, // 90: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 28, // 91: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 31, // 92: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 34, // 93: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 36, // 94: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 36, // 95: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 38, // 96: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 42, // 97: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 12, // 98: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 44, // 99: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 48, // 100: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 51, // 101: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 55, // 102: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 58, // 103: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 60, // 104: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 62, // 105: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 65, // 106: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 67, // 107: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 70, // 108: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 72, // 109: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 74, // 110: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 76, // 111: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 78, // 112: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 83, // 113: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 10, // 114: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 82, // [82:115] is the sub-list for method output_type - 49, // [49:82] is the sub-list for method input_type - 49, // [49:49] is the sub-list for extension type_name - 49, // [49:49] is the sub-list for extension extendee - 0, // [0:49] is the sub-list for field type_name + 89, // 24: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 25: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 26: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 27: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 28: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 29: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 30: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 31: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 51, // 32: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 33: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 34: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 35: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 92, // 36: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 37: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 38: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 39: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 40: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 41: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 42: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 43: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 44: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 45: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 46: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 47: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 48: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 49: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 50: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 51: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 52: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 53: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 54: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 55: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 56: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 57: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 58: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 59: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 60: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 61: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 62: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 63: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 64: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 65: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 66: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 67: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 68: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 69: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 70: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 71: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 72: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 73: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 74: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 75: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 76: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 77: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 78: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 79: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 80: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 81: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 82: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 83: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 84: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 85: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 86: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 87: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 88: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 89: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 90: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 91: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 92: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 93: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 94: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 95: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 96: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 97: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 98: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 99: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 100: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 101: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 102: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 103: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 104: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 105: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 106: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 107: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 108: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 109: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 110: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 111: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 112: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 113: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 114: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 115: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 83, // [83:116] is the sub-list for method output_type + 50, // [50:83] is the sub-list for method input_type + 50, // [50:50] is the sub-list for extension type_name + 50, // [50:50] is the sub-list for extension extendee + 0, // [0:50] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7315,7 +7378,7 @@ func file_client_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), - NumEnums: 9, + NumEnums: 10, NumMessages: 80, NumExtensions: 0, NumServices: 1, diff --git a/looprpc/client.proto b/looprpc/client.proto index cf14ffa0a..b9b3f1830 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1192,6 +1192,18 @@ message GetInfoResponse { message GetLiquidityParamsRequest { } +enum LoopInSource { + /* + Use the legacy wallet-funded loop-in flow. + */ + LOOP_IN_SOURCE_WALLET = 0; + + /* + Use deposited static-address funds for loop-in autoloops. + */ + LOOP_IN_SOURCE_STATIC_ADDRESS = 1; +} + message LiquidityParameters { /* A set of liquidity rules that describe the desired liquidity balance. @@ -1370,6 +1382,11 @@ message LiquidityParameters { considered for easy autoloop swaps. */ repeated bytes easy_autoloop_excluded_peers = 27; + + /* + Selects which source autoloop uses for loop-in rules. + */ + LoopInSource loop_in_source = 28; } message EasyAssetAutoloopParams { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 3d75d15da..fcb865b83 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -2120,6 +2120,10 @@ "format": "byte" }, "description": "A list of peers (their public keys) that should be excluded from the easy\nautoloop run. If set, channels connected to these peers won't be\nconsidered for easy autoloop swaps." + }, + "loop_in_source": { + "$ref": "#/definitions/looprpcLoopInSource", + "description": "Selects which source autoloop uses for loop-in rules." } } }, @@ -2353,6 +2357,15 @@ } } }, + "looprpcLoopInSource": { + "type": "string", + "enum": [ + "LOOP_IN_SOURCE_WALLET", + "LOOP_IN_SOURCE_STATIC_ADDRESS" + ], + "default": "LOOP_IN_SOURCE_WALLET", + "description": " - LOOP_IN_SOURCE_WALLET: Use the legacy wallet-funded loop-in flow.\n - LOOP_IN_SOURCE_STATIC_ADDRESS: Use deposited static-address funds for loop-in autoloops." + }, "looprpcLoopOutRequest": { "type": "object", "properties": { From 97cbd71a8737715da9d12572c13cebed6636282e Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 00:09:14 -0500 Subject: [PATCH 02/10] loopd: validate static loop-in labels at rpc Move static loop-in label validation to the rpc boundary and remove the same check from the internal manager path. This keeps external requests aligned with the existing swap rpc surface while allowing internal autoloop callers to keep using reserved labels for automated swaps. The tests cover both sides of that contract: rpc requests still reject reserved labels, and the manager path accepts them. --- loopd/swapclient_server.go | 7 +++ loopd/swapclient_server_test.go | 18 ++++++ staticaddr/loopin/manager.go | 7 --- staticaddr/loopin/manager_test.go | 97 ++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 62187d3e5..6229cbeb9 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2097,6 +2097,13 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, Fast: in.Fast, } + // External callers must not be able to use reserved autoloop labels. + // Internal autoloop dispatch bypasses this RPC and can still use the + // reserved labels needed to attribute automated swaps correctly. + if err := labels.Validate(req.Label); err != nil { + return nil, fmt.Errorf("invalid label: %w", err) + } + if in.LastHop != nil { lastHop, err := route.NewVertexFromBytes(in.LastHop) if err != nil { diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index a3f29443f..7ee0c6d51 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -261,6 +261,24 @@ func TestValidateLoopInRequest(t *testing.T) { } } +// TestStaticAddressLoopInRejectsReservedLabel verifies that external static +// loop-in requests still reject reserved autoloop labels at the RPC boundary. +func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) { + logger := btclog.NewSLogger( + btclog.NewDefaultHandler(os.Stdout), + ) + setLogger(logger.SubSystem(Subsystem)) + + server := &swapClientServer{} + + _, err := server.StaticAddressLoopIn( + t.Context(), &looprpc.StaticAddressLoopInRequest{ + Label: labels.AutoloopLabel(swap.TypeIn), + }, + ) + require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) +} + // TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the // daemon shutdown. func TestSwapClientServerStopDaemon(t *testing.T) { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 444ab5856..70576d6bd 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -19,7 +19,6 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" - "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/staticutil" @@ -692,12 +691,6 @@ func (m *Manager) initiateLoopIn(ctx context.Context, err) } - // Check that the label is valid. - err = labels.Validate(req.Label) - if err != nil { - return nil, fmt.Errorf("invalid label: %w", err) - } - // Private and route hints are mutually exclusive as setting private // means we retrieve our own route hints from the connected node. if len(req.RouteHints) != 0 && req.Private { diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index d908a9e16..c965f7cfa 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -2,15 +2,21 @@ package loopin import ( "context" + "errors" "testing" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/swap" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -176,8 +182,46 @@ func TestSelectDeposits(t *testing.T) { } } +// TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal +// loop-in manager path does not reject reserved autoloop labels. The RPC +// boundary owns that validation, while internal autoloop dispatch must be able +// to reuse the reserved labels directly. +func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { + ctx := t.Context() + + selectedDeposit := makeDeposit(1, 0, 9_000) + selectedOutpoint := selectedDeposit.OutPoint.String() + quoteErr := errors.New("quote failed") + quoteGetter := &mockQuoteGetter{ + err: quoteErr, + } + + manager, err := NewManager(&Config{ + DepositManager: &mockDepositManager{ + byOutpoint: map[string]*deposit.Deposit{ + selectedOutpoint: selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + _, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{ + DepositOutpoints: []string{selectedOutpoint}, + SelectedAmount: selectedDeposit.Value, + MaxSwapFee: 1_000, + Label: labels.AutoloopLabel(swap.TypeIn), + Initiator: "autoloop", + }) + require.ErrorIs(t, err, quoteErr) + require.NotContains(t, err.Error(), labels.ErrReservedPrefix.Error()) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} + // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { + // byOutpoint maps outpoint strings to deposits for direct lookups. byOutpoint map[string]*deposit.Deposit } @@ -187,10 +231,28 @@ func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( return nil, nil } -func (m *mockDepositManager) AllStringOutpointsActiveDeposits(_ []string, - _ fsm.StateType) ([]*deposit.Deposit, bool) { +func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string, + state fsm.StateType) ([]*deposit.Deposit, bool) { + + if state != deposit.Deposited { + return nil, false + } + + if m.byOutpoint == nil { + return nil, false + } + + res := make([]*deposit.Deposit, 0, len(outpoints)) + for _, outpoint := range outpoints { + selectedDeposit, ok := m.byOutpoint[outpoint] + if !ok { + return nil, false + } + + res = append(res, selectedDeposit) + } - return nil, false + return res, true } func (m *mockDepositManager) TransitionDeposits(_ context.Context, @@ -217,6 +279,35 @@ func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( return nil, nil } +// mockQuoteGetter returns either a configured quote or a configured error and +// records the quoted amount for assertions. +type mockQuoteGetter struct { + // err is the optional error returned from GetLoopInQuote. + err error + + // amount records the quoted amount. + amount btcutil.Amount +} + +// GetLoopInQuote returns the configured quote result for tests. +func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, + amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex, + _ [][]zpay32.HopHint, initiator string, numDeposits uint32, + fast bool) (*loop.LoopInQuote, error) { + + m.amount = amt + _ = lastHop + _ = initiator + _ = numDeposits + _ = fast + + if m.err != nil { + return nil, m.err + } + + return &loop.LoopInQuote{}, nil +} + // mockStore implements StaticAddressLoopInStore for tests. type mockStore struct { loopIns map[lntypes.Hash]*StaticAddressLoopIn From d8b9524a3b9c940588d12b4f43b95315061c7caa Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 14:55:53 -0500 Subject: [PATCH 03/10] staticaddr/loopin: makeDeposit gets confheight arg Test-only change. This is needed to reuse it in another test. --- staticaddr/loopin/manager_test.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index c965f7cfa..fd65189a9 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -189,7 +189,8 @@ func TestSelectDeposits(t *testing.T) { func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { ctx := t.Context() - selectedDeposit := makeDeposit(1, 0, 9_000) + const confirmationHeight = 0 + selectedDeposit := makeDeposit(1, 0, 9_000, confirmationHeight) selectedOutpoint := selectedDeposit.OutPoint.String() quoteErr := errors.New("quote failed") quoteGetter := &mockQuoteGetter{ @@ -367,8 +368,14 @@ func (s *mockStore) SwapHashesForDepositIDs(_ context.Context, } // helper to create a deposit with specific outpoint and value. -func makeDeposit(h byte, index uint32, value btcutil.Amount) *deposit.Deposit { - d := &deposit.Deposit{Value: value} +func makeDeposit(h byte, index uint32, value btcutil.Amount, + confirmationHeight int64) *deposit.Deposit { + + d := &deposit.Deposit{ + Value: value, + ConfirmationHeight: confirmationHeight, + } + d.Hash = chainhash.Hash{h} d.Index = index var id deposit.ID @@ -421,11 +428,12 @@ func TestCheckChange(t *testing.T) { } // Deposits belonging to different swaps. - s1d1 := makeDeposit(1, 0, 1000) - s1d2 := makeDeposit(1, 1, 2000) - s2d1 := makeDeposit(2, 0, 1500) - s3d1 := makeDeposit(3, 0, 800) - s4d1 := makeDeposit(4, 0, 900) + const confirmationHeight = 0 + s1d1 := makeDeposit(1, 0, 1000, confirmationHeight) + s1d2 := makeDeposit(1, 1, 2000, confirmationHeight) + s2d1 := makeDeposit(2, 0, 1500, confirmationHeight) + s3d1 := makeDeposit(3, 0, 800, confirmationHeight) + s4d1 := makeDeposit(4, 0, 900, confirmationHeight) // Swaps: // A: total 3000, selected 3000 => no change. From aaf148702be4f4bce615578a93c0068c0128b4ee Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 00:11:52 -0500 Subject: [PATCH 04/10] staticaddr: add autoloop loop-in prep Add the static-address helper that prepares full-deposit autoloop loop-ins without dispatching them. The helper selects no-change deposit sets, records explicit outpoints, and quotes the exact selected amount before the planner tries to dispatch anything. The tests cover the full-deposit selector, the quoted request construction, and excluded outpoint handling so later liquidity work can rely on a stable preparation surface. --- staticaddr/loopin/autoloop.go | 254 ++++++++++++++++++++++ staticaddr/loopin/autoloop_test.go | 330 +++++++++++++++++++++++++++++ staticaddr/loopin/manager_test.go | 37 +++- 3 files changed, 612 insertions(+), 9 deletions(-) create mode 100644 staticaddr/loopin/autoloop.go create mode 100644 staticaddr/loopin/autoloop_test.go diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go new file mode 100644 index 000000000..851941e82 --- /dev/null +++ b/staticaddr/loopin/autoloop.go @@ -0,0 +1,254 @@ +package loopin + +import ( + "context" + "errors" + "slices" + "sort" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/routing/route" +) + +var ( + // ErrNoAutoloopCandidate is returned when the static-address side + // cannot build a full-deposit, no-change loop-in candidate that fits + // the planner's requested amount bounds. + ErrNoAutoloopCandidate = errors.New("no autoloop candidate") +) + +// PrepareAutoloopLoopIn builds a static-address loop-in request for autoloop +// without dispatching it. The returned request always uses full deposits, +// explicit outpoints, and an explicit selected amount, so the caller can +// account for the suggestion without depending on static-address internals. +func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context, + lastHop route.Vertex, minAmount, maxAmount btcutil.Amount, label, + initiator string, excludedOutpoints []string) ( + *loop.StaticAddressLoopInRequest, int, bool, error) { + + if minAmount <= 0 || maxAmount < minAmount { + return nil, 0, false, ErrNoAutoloopCandidate + } + + allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( + deposit.Deposited, + ) + if err != nil { + return nil, 0, false, err + } + + params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + if err != nil { + return nil, 0, false, err + } + + excluded := make(map[string]struct{}, len(excludedOutpoints)) + for _, outpoint := range excludedOutpoints { + excluded[outpoint] = struct{}{} + } + + selectedDeposits, err := selectNoChangeDeposits( + maxAmount, minAmount, allDeposits, params.Expiry, + m.currentHeight.Load(), excluded, + ) + if err != nil { + return nil, 0, false, err + } + + selectedAmount := sumOfDeposits(selectedDeposits) + quote, err := m.cfg.QuoteGetter.GetLoopInQuote( + ctx, selectedAmount, m.cfg.NodePubkey, &lastHop, nil, + initiator, uint32(len(selectedDeposits)), false, + ) + if err != nil { + return nil, 0, false, err + } + + outpoints := make([]string, 0, len(selectedDeposits)) + for _, selectedDeposit := range selectedDeposits { + outpoints = append(outpoints, selectedDeposit.OutPoint.String()) + } + + request := &loop.StaticAddressLoopInRequest{ + DepositOutpoints: outpoints, + SelectedAmount: selectedAmount, + MaxSwapFee: quote.SwapFee, + LastHop: &lastHop, + Label: label, + Initiator: initiator, + Fast: false, + } + + return request, len(selectedDeposits), false, nil +} + +// selectNoChangeDeposits chooses the highest-value swappable deposit set whose +// full value stays within the requested range. The selector never creates +// change, so the returned set's total is the actual swap amount. +func selectNoChangeDeposits(maxAmount, minAmount btcutil.Amount, + unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32, + excludedOutpoints map[string]struct{}) ([]*deposit.Deposit, error) { + + // Filter out deposits that cannot safely participate in a loop-in or + // were already allocated to a larger suggestion earlier in the same + // planning pass. + deposits := make([]*deposit.Deposit, 0, len(unfilteredDeposits)) + for _, deposit := range unfilteredDeposits { + if _, ok := excludedOutpoints[deposit.OutPoint.String()]; ok { + continue + } + + swappable := IsSwappable( + uint32(deposit.ConfirmationHeight), blockHeight, + csvExpiry, + ) + if !swappable { + continue + } + + if deposit.Value > maxAmount { + continue + } + + deposits = append(deposits, deposit) + } + + if len(deposits) == 0 { + return nil, ErrNoAutoloopCandidate + } + + // Sort by value so the search finds large feasible totals early. The + // expiry tie-break keeps equal-value deposits deterministic and helps + // the later candidate comparison prefer sooner-expiring funds. + sort.SliceStable(deposits, func(i, j int) bool { + if deposits[i].Value == deposits[j].Value { + return deposits[i].ConfirmationHeight < + deposits[j].ConfirmationHeight + } + + return deposits[i].Value > deposits[j].Value + }) + + // Precompute a suffix sum so branches that cannot possibly beat the + // current best total can be pruned before exploring the expensive part + // of the search tree. + suffixSums := make([]btcutil.Amount, len(deposits)+1) + for i := len(deposits) - 1; i >= 0; i-- { + suffixSums[i] = suffixSums[i+1] + deposits[i].Value + } + + var ( + bestSelection []int + bestTotal btcutil.Amount + ) + + // betterSelection applies the full-deposit ordering: + // 1. highest total not exceeding the target + // 2. fewer deposits + // 3. earlier-expiring deposits + betterSelection := func(candidate []int, total btcutil.Amount) bool { + switch { + case total > bestTotal: + return true + + case total < bestTotal: + return false + + case bestSelection == nil: + return true + + case len(candidate) < len(bestSelection): + return true + + case len(candidate) > len(bestSelection): + return false + } + + // Use signed arithmetic here so an expired deposit cannot wrap + // the residual-life comparison if height updates race the + // earlier swappability filter. + left := make([]int64, len(candidate)) + for i, index := range candidate { + left[i] = deposits[index].ConfirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + right := make([]int64, len(bestSelection)) + for i, index := range bestSelection { + right[i] = deposits[index].ConfirmationHeight + + int64(csvExpiry) - int64(blockHeight) + } + + slices.Sort(left) + slices.Sort(right) + + for i := range left { + if left[i] == right[i] { + continue + } + + return left[i] < right[i] + } + + return false + } + + // search explores include/exclude choices. The branch-and-bound checks + // are intentionally conservative: they only prune when no combination + // below the current node can beat the best known total or tie it with a + // smaller deposit count. + var search func(index int, total btcutil.Amount, selected []int) + search = func(index int, total btcutil.Amount, selected []int) { + if total > maxAmount { + return + } + + if total >= minAmount && betterSelection(selected, total) { + bestTotal = total + bestSelection = append([]int(nil), selected...) + } + + if index == len(deposits) { + return + } + + maxReachable := total + suffixSums[index] + if maxReachable < bestTotal { + return + } + + if maxReachable == bestTotal && bestSelection != nil && + len(selected) >= len(bestSelection) { + + return + } + + // The include branch must not reuse selected's backing array. + // Otherwise a later append can leak into the exclude branch + // when the slice still has spare capacity. + selectedWithIndex := make([]int, len(selected)+1) + copy(selectedWithIndex, selected) + selectedWithIndex[len(selected)] = index + + search( + index+1, total+deposits[index].Value, + selectedWithIndex, + ) + search(index+1, total, selected) + } + + search(0, 0, nil) + + if len(bestSelection) == 0 { + return nil, ErrNoAutoloopCandidate + } + + selectedDeposits := make([]*deposit.Deposit, 0, len(bestSelection)) + for _, index := range bestSelection { + selectedDeposits = append(selectedDeposits, deposits[index]) + } + + return selectedDeposits, nil +} diff --git a/staticaddr/loopin/autoloop_test.go b/staticaddr/loopin/autoloop_test.go new file mode 100644 index 000000000..2e32eea5f --- /dev/null +++ b/staticaddr/loopin/autoloop_test.go @@ -0,0 +1,330 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// TestSelectNoChangeDeposits verifies the full-deposit static-autoloop +// selector. The cases below target the filter paths, the branch-and-bound +// search, and every documented tie-breaker explicitly so coverage tracks the +// actual selection behavior instead of a handful of happy-path examples. +func TestSelectNoChangeDeposits(t *testing.T) { + depositSeven := makeDeposit(7, 0, 7_000, 200) + depositFour := makeDeposit(4, 0, 4_000, 210) + depositThreeA := makeDeposit(3, 0, 3_000, 220) + depositThreeB := makeDeposit(9, 0, 3_000, 221) + depositNine := makeDeposit(8, 0, 9_000, 205) + depositFourA := makeDeposit(5, 0, 4_000, 215) + depositFourB := makeDeposit(6, 0, 4_000, 216) + depositOneA := makeDeposit(10, 0, 1_000, 230) + depositOneB := makeDeposit(11, 0, 1_000, 231) + depositOneC := makeDeposit(21, 0, 1_000, 232) + depositFourC := makeDeposit(13, 0, 4_000, 200) + depositFourD := makeDeposit(14, 0, 4_000, 201) + depositFourE := makeDeposit(15, 0, 4_000, 220) + depositFourF := makeDeposit(16, 0, 4_000, 221) + depositFive := makeDeposit(17, 0, 5_000, 200) + depositUnsuitable := makeDeposit(18, 0, 6_000, 149) + depositOversized := makeDeposit(19, 0, 9_000, 220) + depositTwo := makeDeposit(20, 0, 2_000, 210) + + testCases := []struct { + name string + maxAmount btcutil.Amount + minAmount btcutil.Amount + deposits []*deposit.Deposit + csvExpiry uint32 + blockHeight uint32 + excludedOutpoint map[string]struct{} + expected []*deposit.Deposit + expectedErr error + }{ + { + name: "prefers exact deposit over smaller combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositSeven}, + }, + { + name: "excluded outpoint falls back to combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + excludedOutpoint: map[string]struct{}{ + depositSeven.OutPoint.String(): {}, + }, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "same total prefers fewer deposits", + maxAmount: 6_000, + minAmount: 6_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, depositThreeB, + depositOneA, depositOneB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositThreeA, depositThreeB, + }, + }, + { + name: "same total rejects more deposits", + maxAmount: 2_000, + minAmount: 2_000, + deposits: []*deposit.Deposit{ + depositTwo, depositOneA, + depositOneB, depositOneC, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositTwo}, + }, + { + name: "same total prefers earlier expiries", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFourC, depositFourD, + depositFourE, depositFourF, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFourC, depositFourD, + }, + }, + { + name: "identical residual lives keep stable pick", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + depositFourA, depositThreeB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositFourA, + }, + }, + { + name: "filters unswappable and oversized deposits", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositFive, depositUnsuitable, + depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositFive}, + }, + { + name: "returns no candidate when all are filtered", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositUnsuitable, depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "returns no candidate below minimum", + maxAmount: 10_000, + minAmount: 7_000, + deposits: []*deposit.Deposit{ + depositFour, depositTwo, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "zero minimum finds best positive total", + maxAmount: 7_000, + minAmount: 0, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "deeper search isolates include and exclude", + maxAmount: 13_000, + minAmount: 10_000, + deposits: []*deposit.Deposit{ + depositNine, depositSeven, + depositFourA, depositFourB, + depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositNine, depositFourA, + }, + }, + } + + selectedOutpoints := func(deposits []*deposit.Deposit) []string { + result := make([]string, 0, len(deposits)) + for _, selectedDeposit := range deposits { + result = append( + result, selectedDeposit.OutPoint.String(), + ) + } + + return result + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + selectedDeposits, err := selectNoChangeDeposits( + testCase.maxAmount, testCase.minAmount, + testCase.deposits, testCase.csvExpiry, + testCase.blockHeight, testCase.excludedOutpoint, + ) + + if testCase.expectedErr != nil { + require.ErrorIs(t, err, testCase.expectedErr) + require.Nil(t, selectedDeposits) + } else { + require.NoError(t, err) + require.Equal( + t, selectedOutpoints(testCase.expected), + selectedOutpoints(selectedDeposits), + ) + } + }) + } +} + +// TestPrepareAutoloopLoopIn ensures the static manager returns an explicit +// full-deposit request and quotes it with the correct amount and deposit +// count. +func TestPrepareAutoloopLoopIn(t *testing.T) { + ctx := t.Context() + + selectedDeposit := makeDeposit(1, 0, 9_000, 300) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 123, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{selectedDeposit}, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + lastHop := route.Vertex{9} + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, lastHop, 5_000, 10_000, "label", "autoloop", nil, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(123), request.MaxSwapFee) + require.NotNil(t, request.LastHop) + require.Equal(t, lastHop, *request.LastHop) + require.Equal(t, "label", request.Label) + require.Equal(t, "autoloop", request.Initiator) + require.False(t, request.Fast) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) + require.NotNil(t, quoteGetter.lastHop) + require.Equal(t, lastHop, *quoteGetter.lastHop) + require.Equal(t, "autoloop", quoteGetter.initiator) + require.Equal(t, uint32(1), quoteGetter.numDeposits) + require.False(t, quoteGetter.fast) +} + +// TestPrepareAutoloopLoopInExcludedOutpoints verifies that the manager passes +// excluded outpoints through the end-to-end preparation path before quoting +// the candidate. +func TestPrepareAutoloopLoopInExcludedOutpoints(t *testing.T) { + ctx := t.Context() + + excludedDeposit := makeDeposit(1, 0, 9_000, 300) + + selectedDeposit := makeDeposit(2, 0, 7_000, 301) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 77, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &address.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + excludedDeposit, selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, route.Vertex{9}, 5_000, 10_000, "label", "autoloop", + []string{excludedDeposit.OutPoint.String()}, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(77), request.MaxSwapFee) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index fd65189a9..cb3b6f07b 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -222,6 +222,9 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { + // activeDeposits is the set returned by GetActiveDepositsInState. + activeDeposits []*deposit.Deposit + // byOutpoint maps outpoint strings to deposits for direct lookups. byOutpoint map[string]*deposit.Deposit } @@ -277,36 +280,52 @@ func (m *mockDepositManager) DepositsForOutpoints(_ context.Context, func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( []*deposit.Deposit, error) { - return nil, nil + return m.activeDeposits, nil } -// mockQuoteGetter returns either a configured quote or a configured error and -// records the quoted amount for assertions. +// mockQuoteGetter records the inputs to quote requests and returns a fixed +// loop-in quote. type mockQuoteGetter struct { + // quote is the response returned from GetLoopInQuote. + quote *loop.LoopInQuote + // err is the optional error returned from GetLoopInQuote. err error // amount records the quoted amount. amount btcutil.Amount + + // lastHop records the quoted last hop. + lastHop *route.Vertex + + // initiator records the quoted initiator string. + initiator string + + // numDeposits records the quoted deposit count. + numDeposits uint32 + + // fast records the quoted fast flag. + fast bool } -// GetLoopInQuote returns the configured quote result for tests. +// GetLoopInQuote returns the configured quote and records the request +// parameters for assertions. func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex, _ [][]zpay32.HopHint, initiator string, numDeposits uint32, fast bool) (*loop.LoopInQuote, error) { m.amount = amt - _ = lastHop - _ = initiator - _ = numDeposits - _ = fast + m.lastHop = lastHop + m.initiator = initiator + m.numDeposits = numDeposits + m.fast = fast if m.err != nil { return nil, m.err } - return &loop.LoopInQuote{}, nil + return m.quote, nil } // mockStore implements StaticAddressLoopInStore for tests. From 7a5dbb3096dbec01e557ffb088ddf8841026d8a5 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:51:19 -0500 Subject: [PATCH 05/10] 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. --- liquidity/easy_autoloop_exclusions_test.go | 21 +- liquidity/liquidity.go | 215 ++++++++++++++-- liquidity/liquidity_test.go | 128 +++++++++- liquidity/static_loopin.go | 122 +++++++++ liquidity/static_loopin_test.go | 276 +++++++++++++++++++++ loopd/daemon.go | 6 +- loopd/utils.go | 48 +++- staticaddr/loopin/loopin.go | 19 ++ staticaddr/loopin/sql_store.go | 1 + staticaddr/loopin/sql_store_test.go | 33 ++- 10 files changed, 822 insertions(+), 47 deletions(-) create mode 100644 liquidity/static_loopin.go create mode 100644 liquidity/static_loopin_test.go diff --git a/liquidity/easy_autoloop_exclusions_test.go b/liquidity/easy_autoloop_exclusions_test.go index b70af97c7..e6e236274 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 b31e2b003..ceb735e4f 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 3824ce9ce..77744733b 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 000000000..338b64b21 --- /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 000000000..12b227c46 --- /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 880e19621..9b00f4e07 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 9b439ac5a..5a98ae97a 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 37616c675..a1a702880 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -28,6 +28,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" ) @@ -106,6 +107,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 @@ -487,6 +491,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 1b70bbc48..d06c5c181 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 356049bc7..1e30081dc 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, + ) } From fc419b0ac2b41486508b40d02192bd43be8a3d56 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:58:29 -0500 Subject: [PATCH 06/10] looprpc: expose static autoloop output Extend the public rpc surface for static autoloop integration without turning the planner on yet. SuggestSwaps responses can now carry static-address loop-in requests and the new planner reason for missing static candidates is mapped over rpc. --- liquidity/liquidity.go | 4 + liquidity/reasons.go | 8 ++ loopd/swapclient_server.go | 26 ++++ loopd/swapclient_server_test.go | 15 +++ looprpc/client.pb.go | 231 +++++++++++++++++--------------- looprpc/client.proto | 11 ++ looprpc/client.swagger.json | 13 +- 7 files changed, 199 insertions(+), 109 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index ceb735e4f..2db419b3c 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -933,6 +933,10 @@ type Suggestions struct { // InSwaps is the set of loop in swaps that we suggest executing. InSwaps []loop.LoopInRequest + // StaticInSwaps is the set of static-address loop-ins that we suggest + // executing. + StaticInSwaps []loop.StaticAddressLoopInRequest + // DisqualifiedChans maps the set of channels that we do not recommend // swaps on to the reason that we did not recommend a swap. DisqualifiedChans map[lnwire.ShortChannelID]Reason diff --git a/liquidity/reasons.go b/liquidity/reasons.go index a73f9000a..5f7cf618d 100644 --- a/liquidity/reasons.go +++ b/liquidity/reasons.go @@ -73,6 +73,11 @@ const ( // ReasonCustomChannelData indicates that the channel is not standard // and should not be used for swaps. ReasonCustomChannelData + + // ReasonStaticLoopInNoCandidate indicates that static loop-in + // autoloop was selected, but no full-deposit static candidate was + // available for the target peer. + ReasonStaticLoopInNoCandidate ) // String returns a string representation of a reason. @@ -123,6 +128,9 @@ func (r Reason) String() string { case ReasonLoopInUnreachable: return "loop in unreachable" + case ReasonStaticLoopInNoCandidate: + return "no static loop-in candidate" + default: return "unknown" } diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 6229cbeb9..043538230 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1426,6 +1426,10 @@ func (s *swapClientServer) SuggestSwaps(ctx context.Context, LoopIn: make( []*looprpc.LoopInRequest, len(suggestions.InSwaps), ), + StaticLoopIn: make( + []*looprpc.StaticAddressLoopInRequest, + len(suggestions.StaticInSwaps), + ), } for i, swap := range suggestions.OutSwaps { @@ -1456,6 +1460,24 @@ func (s *swapClientServer) SuggestSwaps(ctx context.Context, resp.LoopIn[i] = loopIn } + for i, swap := range suggestions.StaticInSwaps { + request := &looprpc.StaticAddressLoopInRequest{ + Outpoints: swap.DepositOutpoints, + MaxSwapFeeSatoshis: int64(swap.MaxSwapFee), + Label: swap.Label, + Initiator: swap.Initiator, + PaymentTimeoutSeconds: swap.PaymentTimeoutSeconds, + Amount: int64(swap.SelectedAmount), + Fast: swap.Fast, + } + + if swap.LastHop != nil { + request.LastHop = swap.LastHop[:] + } + + resp.StaticLoopIn[i] = request + } + for id, reason := range suggestions.DisqualifiedChans { autoloopReason, err := rpcAutoloopReason(reason) if err != nil { @@ -2416,6 +2438,10 @@ func rpcAutoloopReason(reason liquidity.Reason) (looprpc.AutoReason, error) { case liquidity.ReasonFeePPMInsufficient: return looprpc.AutoReason_AUTO_REASON_SWAP_FEE, nil + case liquidity.ReasonStaticLoopInNoCandidate: + return looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE, + nil + default: return 0, fmt.Errorf("unknown autoloop reason: %v", reason) } diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index 7ee0c6d51..f06c9d07e 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -15,6 +15,7 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/liquidity" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" @@ -279,6 +280,20 @@ func TestStaticAddressLoopInRejectsReservedLabel(t *testing.T) { require.ErrorContains(t, err, labels.ErrReservedPrefix.Error()) } +// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner +// reason is exposed over rpc. +func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) { + reason, err := rpcAutoloopReason( + liquidity.ReasonStaticLoopInNoCandidate, + ) + require.NoError(t, err) + require.Equal( + t, + looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE, + reason, + ) +} + // TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the // daemon shutdown. func TestSwapClientServerStopDaemon(t *testing.T) { diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 87bf5bb1d..27b8b0fdf 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -424,6 +424,9 @@ const ( // Fee insufficient indicates that the fee estimate for a swap is higher than // the portion of total swap amount that we allow fees to consume. AutoReason_AUTO_REASON_FEE_INSUFFICIENT AutoReason = 13 + // No static loop-in candidate indicates that static loop-in autoloop was + // selected, but no full-deposit static candidate fit the rule. + AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE AutoReason = 14 ) // Enum value maps for AutoReason. @@ -443,22 +446,24 @@ var ( 11: "AUTO_REASON_LIQUIDITY_OK", 12: "AUTO_REASON_BUDGET_INSUFFICIENT", 13: "AUTO_REASON_FEE_INSUFFICIENT", + 14: "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE", } AutoReason_value = map[string]int32{ - "AUTO_REASON_UNKNOWN": 0, - "AUTO_REASON_BUDGET_NOT_STARTED": 1, - "AUTO_REASON_SWEEP_FEES": 2, - "AUTO_REASON_BUDGET_ELAPSED": 3, - "AUTO_REASON_IN_FLIGHT": 4, - "AUTO_REASON_SWAP_FEE": 5, - "AUTO_REASON_MINER_FEE": 6, - "AUTO_REASON_PREPAY": 7, - "AUTO_REASON_FAILURE_BACKOFF": 8, - "AUTO_REASON_LOOP_OUT": 9, - "AUTO_REASON_LOOP_IN": 10, - "AUTO_REASON_LIQUIDITY_OK": 11, - "AUTO_REASON_BUDGET_INSUFFICIENT": 12, - "AUTO_REASON_FEE_INSUFFICIENT": 13, + "AUTO_REASON_UNKNOWN": 0, + "AUTO_REASON_BUDGET_NOT_STARTED": 1, + "AUTO_REASON_SWEEP_FEES": 2, + "AUTO_REASON_BUDGET_ELAPSED": 3, + "AUTO_REASON_IN_FLIGHT": 4, + "AUTO_REASON_SWAP_FEE": 5, + "AUTO_REASON_MINER_FEE": 6, + "AUTO_REASON_PREPAY": 7, + "AUTO_REASON_FAILURE_BACKOFF": 8, + "AUTO_REASON_LOOP_OUT": 9, + "AUTO_REASON_LOOP_IN": 10, + "AUTO_REASON_LIQUIDITY_OK": 11, + "AUTO_REASON_BUDGET_INSUFFICIENT": 12, + "AUTO_REASON_FEE_INSUFFICIENT": 13, + "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE": 14, } ) @@ -4081,6 +4086,8 @@ type SuggestSwapsResponse struct { LoopOut []*LoopOutRequest `protobuf:"bytes,1,rep,name=loop_out,json=loopOut,proto3" json:"loop_out,omitempty"` // The set of recommended loop in swaps LoopIn []*LoopInRequest `protobuf:"bytes,3,rep,name=loop_in,json=loopIn,proto3" json:"loop_in,omitempty"` + // The set of recommended static-address loop in swaps. + StaticLoopIn []*StaticAddressLoopInRequest `protobuf:"bytes,4,rep,name=static_loop_in,json=staticLoopIn,proto3" json:"static_loop_in,omitempty"` // Disqualified contains the set of channels that swaps are not recommended // for. Disqualified []*Disqualified `protobuf:"bytes,2,rep,name=disqualified,proto3" json:"disqualified,omitempty"` @@ -4132,6 +4139,13 @@ func (x *SuggestSwapsResponse) GetLoopIn() []*LoopInRequest { return nil } +func (x *SuggestSwapsResponse) GetStaticLoopIn() []*StaticAddressLoopInRequest { + if x != nil { + return x.StaticLoopIn + } + return nil +} + func (x *SuggestSwapsResponse) GetDisqualified() []*Disqualified { if x != nil { return x.Disqualified @@ -6844,10 +6858,11 @@ const file_client_proto_rawDesc = "" + "\n" + "channel_id\x18\x01 \x01(\x04R\tchannelId\x12\x16\n" + "\x06pubkey\x18\x03 \x01(\fR\x06pubkey\x12+\n" + - "\x06reason\x18\x02 \x01(\x0e2\x13.looprpc.AutoReasonR\x06reason\"\xb6\x01\n" + + "\x06reason\x18\x02 \x01(\x0e2\x13.looprpc.AutoReasonR\x06reason\"\x81\x02\n" + "\x14SuggestSwapsResponse\x122\n" + "\bloop_out\x18\x01 \x03(\v2\x17.looprpc.LoopOutRequestR\aloopOut\x12/\n" + - "\aloop_in\x18\x03 \x03(\v2\x16.looprpc.LoopInRequestR\x06loopIn\x129\n" + + "\aloop_in\x18\x03 \x03(\v2\x16.looprpc.LoopInRequestR\x06loopIn\x12I\n" + + "\x0estatic_loop_in\x18\x04 \x03(\v2#.looprpc.StaticAddressLoopInRequestR\fstaticLoopIn\x129\n" + "\fdisqualified\x18\x02 \x03(\v2\x15.looprpc.DisqualifiedR\fdisqualified\"W\n" + "\x12AbandonSwapRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\fR\x02id\x121\n" + @@ -7044,7 +7059,7 @@ const file_client_proto_rawDesc = "" + "\x1dLOOP_IN_SOURCE_STATIC_ADDRESS\x10\x01*/\n" + "\x11LiquidityRuleType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\r\n" + - "\tTHRESHOLD\x10\x01*\xa6\x03\n" + + "\tTHRESHOLD\x10\x01*\xd3\x03\n" + "\n" + "AutoReason\x12\x17\n" + "\x13AUTO_REASON_UNKNOWN\x10\x00\x12\"\n" + @@ -7061,7 +7076,8 @@ const file_client_proto_rawDesc = "" + "\x12\x1c\n" + "\x18AUTO_REASON_LIQUIDITY_OK\x10\v\x12#\n" + "\x1fAUTO_REASON_BUDGET_INSUFFICIENT\x10\f\x12 \n" + - "\x1cAUTO_REASON_FEE_INSUFFICIENT\x10\r*\x88\x02\n" + + "\x1cAUTO_REASON_FEE_INSUFFICIENT\x10\r\x12+\n" + + "'AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE\x10\x0e*\x88\x02\n" + "\fDepositState\x12\x11\n" + "\rUNKNOWN_STATE\x10\x00\x12\r\n" + "\tDEPOSITED\x10\x01\x12\x0f\n" + @@ -7272,95 +7288,96 @@ var file_client_proto_depIdxs = []int32{ 6, // 29: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason 14, // 30: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest 15, // 31: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 51, // 32: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 57, // 33: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 64, // 34: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 35: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 92, // 36: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 37: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 38: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 39: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 40: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 41: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 42: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 43: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 44: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 91, // 45: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 46: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 47: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 48: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 49: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 50: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 51: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 52: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 53: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 54: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 55: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 56: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 57: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 58: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 59: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 60: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 61: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 62: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 63: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 64: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 65: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 66: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 67: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 68: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 69: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 70: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 71: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 72: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 73: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 74: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 75: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 76: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 77: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 78: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 79: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 80: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 81: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 82: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 83: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 84: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 85: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 86: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 87: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 88: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 89: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 90: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 91: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 92: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 93: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 94: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 95: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 96: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 97: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 98: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 99: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 100: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 101: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 102: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 103: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 104: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 105: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 106: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 107: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 108: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 109: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 110: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 111: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 112: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 113: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 114: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 115: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 83, // [83:116] is the sub-list for method output_type - 50, // [50:83] is the sub-list for method input_type - 50, // [50:50] is the sub-list for extension type_name - 50, // [50:50] is the sub-list for extension extendee - 0, // [0:50] is the sub-list for field type_name + 83, // 32: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 33: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 34: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 35: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 36: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 92, // 37: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 38: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 39: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 40: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 41: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 42: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 43: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 44: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 45: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 91, // 46: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 47: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 87, // 48: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 87, // 49: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 50: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 51: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 52: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 53: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 54: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 55: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 56: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 57: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 58: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 59: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 60: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 61: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 62: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 63: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 64: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 65: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 66: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 67: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 68: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 69: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 70: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 71: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 72: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 73: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 74: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 75: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 76: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 77: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 78: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 79: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 80: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 81: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 82: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 83: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 84: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 85: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 86: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 87: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 88: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 89: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 90: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 91: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 92: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 93: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 94: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 95: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 96: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 97: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 98: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 99: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 100: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 101: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 102: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 103: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 104: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 105: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 106: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 107: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 108: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 109: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 110: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 111: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 112: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 113: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 114: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 115: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 116: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 84, // [84:117] is the sub-list for method output_type + 51, // [51:84] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_client_proto_init() } diff --git a/looprpc/client.proto b/looprpc/client.proto index b9b3f1830..7ab3444c2 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1543,6 +1543,12 @@ enum AutoReason { the portion of total swap amount that we allow fees to consume. */ AUTO_REASON_FEE_INSUFFICIENT = 13; + + /* + No static loop-in candidate indicates that static loop-in autoloop was + selected, but no full-deposit static candidate fit the rule. + */ + AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE = 14; } message Disqualified { @@ -1573,6 +1579,11 @@ message SuggestSwapsResponse { */ repeated LoopInRequest loop_in = 3; + /* + The set of recommended static-address loop in swaps. + */ + repeated StaticAddressLoopInRequest static_loop_in = 4; + /* Disqualified contains the set of channels that swaps are not recommended for. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index fcb865b83..9a3a4bca3 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1543,10 +1543,11 @@ "AUTO_REASON_LOOP_IN", "AUTO_REASON_LIQUIDITY_OK", "AUTO_REASON_BUDGET_INSUFFICIENT", - "AUTO_REASON_FEE_INSUFFICIENT" + "AUTO_REASON_FEE_INSUFFICIENT", + "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE" ], "default": "AUTO_REASON_UNKNOWN", - "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume." + "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume.\n - AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE: No static loop-in candidate indicates that static loop-in autoloop was\nselected, but no full-deposit static candidate fit the rule." }, "looprpcClientReservation": { "type": "object", @@ -2948,6 +2949,14 @@ }, "title": "The set of recommended loop in swaps" }, + "static_loop_in": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/looprpcStaticAddressLoopInRequest" + }, + "description": "The set of recommended static-address loop in swaps." + }, "disqualified": { "type": "array", "items": { From d4a16804ccadf137e0055bff52c531474b82eea9 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 11 Apr 2026 00:00:25 -0500 Subject: [PATCH 07/10] liquidity: add static autoloop planner Wire static-address-backed loop-ins into the existing autoloop planner and dispatch path. Loop-in rules can now be converted into static candidates, prepared after global sorting, filtered with static fee limits, and dispatched through the static manager. This also fixes MaxAutoInFlight enforcement across all suggested swap types and adds planner tests for missing static candidates and mixed in-flight filtering. --- liquidity/liquidity.go | 207 ++++++++++++++++++++++++++++---- liquidity/static_loopin.go | 177 +++++++++++++++++++++++++++ liquidity/static_loopin_test.go | 198 ++++++++++++++++++++++++++++++ loopd/utils.go | 56 +++++++++ 4 files changed, 617 insertions(+), 21 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 2db419b3c..8a32adcd2 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -213,6 +213,19 @@ type Config struct { LoopIn func(ctx context.Context, request *loop.LoopInRequest) (*loop.LoopInSwapInfo, error) + // PrepareStaticLoopIn builds a static-address-backed loop-in request + // for autoloop without dispatching it. The excluded outpoints set lets + // the planner avoid reusing deposits across multiple suggestions from + // the same pass. + PrepareStaticLoopIn func(ctx context.Context, peer route.Vertex, + minAmount, amount btcutil.Amount, label, initiator string, + excludedOutpoints []string) (*PreparedStaticLoopIn, error) + + // StaticLoopIn dispatches a prepared static-address-backed loop-in. + StaticLoopIn func(ctx context.Context, + request *loop.StaticAddressLoopInRequest) ( + *StaticLoopInDispatchResult, error) + // LoopInTerms returns the terms for a loop in swap. LoopInTerms func(ctx context.Context, initiator string) (*loop.LoopInTerms, error) @@ -502,6 +515,30 @@ func (m *Manager) autoloop(ctx context.Context) error { loopIn.HtlcAddressP2WSH, loopIn.HtlcAddressP2TR) } + for _, in := range suggestion.StaticInSwaps { + // Static loop-ins follow the same dry-run semantics as the legacy + // autoloop suggestions. We only dispatch them when autoloop is + // actually enabled. + if !m.params.Autoloop { + log.Debugf("recommended static autoloop in: %v sats "+ + "over %v", in.SelectedAmount, in.DepositOutpoints) + + continue + } + + if m.cfg.StaticLoopIn == nil { + return errors.New("static loop in dispatcher unavailable") + } + + loopIn, err := m.cfg.StaticLoopIn(ctx, &in) + if err != nil { + return err + } + + log.Infof("static loop in automatically dispatched: hash: %v", + loopIn.SwapHash) + } + return nil } @@ -662,6 +699,7 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error { if err != nil { return err } + if channel == nil { return fmt.Errorf("no eligible channel for easy autoloop") } @@ -865,6 +903,7 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context, if err != nil { return err } + if channel == nil { return fmt.Errorf("no eligible channel for easy autoloop") } @@ -961,6 +1000,9 @@ func (s *Suggestions) addSwap(swap swapSuggestion) error { case *loopInSwapSuggestion: s.InSwaps = append(s.InSwaps, t.LoopInRequest) + case *staticLoopInSwapSuggestion: + s.StaticInSwaps = append(s.StaticInSwaps, t.request) + default: return fmt.Errorf("unexpected swap type: %T", swap) } @@ -968,6 +1010,25 @@ func (s *Suggestions) addSwap(swap swapSuggestion) error { return nil } +// count returns the total number of accepted suggestions regardless of swap +// type. +func (s *Suggestions) count() int { + return len(s.OutSwaps) + len(s.InSwaps) + len(s.StaticInSwaps) +} + +// suggestionCandidate is the shared view used while ordering suggestions +// before final budget and in-flight filtering. +type suggestionCandidate interface { + // amount returns the requested swap amount. + amount() btcutil.Amount + + // channels returns the channels implicated by the candidate. + channels() []lnwire.ShortChannelID + + // peers returns the peers implicated by the candidate. + peers(knownChans map[uint64]route.Vertex) []route.Vertex +} + // singleReasonSuggestion is a helper function which returns a set of // suggestions where all of our rules are disqualified due to a reason that // applies to all of them (such as being out of budget). @@ -985,12 +1046,11 @@ func (m *Manager) singleReasonSuggestion(reason Reason) *Suggestions { return resp } -// SuggestSwaps returns a set of swap suggestions based on our current liquidity -// balance for the set of rules configured for the manager, failing if there are -// no rules set. It takes an autoloop boolean that indicates whether the -// suggestions are being used for our internal autolooper. This boolean is used -// to determine the information we add to our swap suggestion and whether we -// return any suggestions. +// SuggestSwaps returns a set of swap suggestions based on our current +// liquidity balance for the rules configured on the manager. The planner +// fails when no rules are set and otherwise returns both suggested swaps and +// structured disqualification reasons for rules that could not be satisfied in +// the current pass. func (m *Manager) SuggestSwaps(ctx context.Context) ( *Suggestions, error) { @@ -1086,8 +1146,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( ) var ( - suggestions []swapSuggestion - resp = newSuggestions() + candidates []suggestionCandidate + resp = newSuggestions() ) for peer, balances := range peerChannels { @@ -1110,7 +1170,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( return nil, err } - suggestions = append(suggestions, suggestion) + candidates = append(candidates, suggestion) } for _, channel := range channels { @@ -1145,18 +1205,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( return nil, err } - suggestions = append(suggestions, suggestion) + candidates = append(candidates, suggestion) } // If we have no swaps to execute after we have applied all of our // limits, just return our set of disqualified swaps. - if len(suggestions) == 0 { + if len(candidates) == 0 { return resp, nil } // Sort suggestions by amount in descending order. - sort.SliceStable(suggestions, func(i, j int) bool { - return suggestions[i].amount() > suggestions[j].amount() + sort.SliceStable(candidates, func(i, j int) bool { + return candidates[i].amount() > candidates[j].amount() }) // Run through our suggested swaps in descending order of amount and @@ -1165,8 +1225,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( // setReason is a helper that adds a swap's channels to our disqualified // list with the reason provided. - setReason := func(reason Reason, swap swapSuggestion) { - for _, peer := range swap.peers(channelPeers) { + setReason := func(reason Reason, candidate suggestionCandidate) { + for _, peer := range candidate.peers(channelPeers) { _, ok := m.params.PeerRules[peer] if !ok { continue @@ -1175,7 +1235,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( resp.DisqualifiedPeers[peer] = reason } - for _, channel := range swap.channels() { + for _, channel := range candidate.channels() { _, ok := m.params.ChannelRules[channel] if !ok { continue @@ -1185,7 +1245,40 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( } } - for _, swap := range suggestions { + var excludedOutpoints []string + for _, candidate := range candidates { + var swap swapSuggestion + + switch t := candidate.(type) { + case swapSuggestion: + swap = t + + case *staticLoopInCandidate: + swap, excludedOutpoints, err = m.prepareStaticLoopInSuggestion( + ctx, t, excludedOutpoints, + ) + switch { + case errors.Is(err, ErrNoStaticLoopInCandidate): + setReason(ReasonStaticLoopInNoCandidate, candidate) + continue + + case err == nil: + + default: + var reasonErr *reasonError + if errors.As(err, &reasonErr) { + setReason(reasonErr.reason, candidate) + continue + } + + return nil, err + } + + default: + return nil, fmt.Errorf("unexpected candidate type: %T", + candidate) + } + // If we do not have enough funds available, or we hit our // in flight limit, we record this value for the rest of the // swaps. @@ -1194,12 +1287,12 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( case available == 0: reason = ReasonBudgetInsufficient - case len(resp.OutSwaps) == allowedSwaps: + case resp.count() == allowedSwaps: reason = ReasonInFlight } if reason != ReasonNone { - setReason(reason, swap) + setReason(reason, candidate) continue } @@ -1221,7 +1314,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( log.Infof("Swap fee exceeds budget, remaining budget: "+ "%v, swap fee %v, next budget refresh: %v", available, fees, refreshTime) - setReason(ReasonBudgetInsufficient, swap) + setReason(ReasonBudgetInsufficient, candidate) } } @@ -1240,11 +1333,66 @@ func (m *Manager) loadStaticLoopIns(ctx context.Context) ( return m.cfg.ListStaticLoopIn(ctx) } +// prepareStaticLoopInSuggestion turns a peer-level static loop-in candidate +// into a concrete swap suggestion. The helper only runs after all candidates +// have been sorted so it can carry a mutable excluded-deposit set through the +// whole planner pass. +func (m *Manager) prepareStaticLoopInSuggestion(ctx context.Context, + candidate *staticLoopInCandidate, + excludedOutpoints []string) (swapSuggestion, []string, error) { + + if m.cfg.PrepareStaticLoopIn == nil { + return nil, excludedOutpoints, errors.New( + "static loop in preparer unavailable", + ) + } + + label := "" + if m.params.Autoloop { + label = labels.AutoloopLabel(swap.TypeIn) + if m.params.EasyAutoloop { + label = labels.EasyAutoloopLabel(swap.TypeIn) + } + } + + prepared, err := m.cfg.PrepareStaticLoopIn( + ctx, candidate.peer, candidate.minAmount, candidate.amountHint, + label, + getInitiator(m.params), excludedOutpoints, + ) + if err != nil { + return nil, excludedOutpoints, err + } + + // Static loop-ins have a different timeout-risk profile than + // wallet-funded loop-ins, so use the dedicated static fee model before + // the candidate can compete for budget and in-flight slots. + err = staticLoopInFeeLimit( + m.params.FeeLimit, prepared.Request.SelectedAmount, + prepared.Request.MaxSwapFee, prepared.NumDeposits, + prepared.HasChange, + ) + if err != nil { + return nil, excludedOutpoints, err + } + + nextExcluded := append( + append([]string(nil), excludedOutpoints...), + prepared.Request.DepositOutpoints..., + ) + + return &staticLoopInSwapSuggestion{ + request: prepared.Request, + numDeposits: prepared.NumDeposits, + hasChange: prepared.HasChange, + }, nextExcluded, nil +} + // 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, balance *balances, rule *SwapRule, outRestrictions *Restrictions, - inRestrictions *Restrictions) (swapSuggestion, error) { + inRestrictions *Restrictions) (suggestionCandidate, error) { var ( builder swapBuilder @@ -1288,6 +1436,23 @@ func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, return nil, newReasonError(ReasonLiquidityOk) } + // Static loop-ins are prepared later, once the planner has a sorted + // view of all loop-in candidates. That later step needs a mutable set + // of excluded deposits so that two suggestions in the same pass cannot + // consume the same static funds. + if rule.Type == swap.TypeIn && + m.params.LoopInSource == LoopInSourceStaticAddress { + + return &staticLoopInCandidate{ + peer: balance.pubkey, + minAmount: restrictions.Minimum, + amountHint: amount, + channelSet: append( + []lnwire.ShortChannelID(nil), balance.channels..., + ), + }, nil + } + return builder.buildSwap( ctx, balance.pubkey, balance.channels, amount, m.params, ) diff --git a/liquidity/static_loopin.go b/liquidity/static_loopin.go index 338b64b21..bfdcafbce 100644 --- a/liquidity/static_loopin.go +++ b/liquidity/static_loopin.go @@ -1,16 +1,58 @@ package liquidity import ( + "errors" + "fmt" "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/txscript" + "github.com/lightninglabs/loop" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" ) +var ( + // ErrNoStaticLoopInCandidate is returned when the static-address side + // is unable to build a full-deposit, no-change candidate for an + // autoloop target. This sentinel lets the planner surface a structured + // reason without silently falling back to wallet-funded loop-ins. + ErrNoStaticLoopInCandidate = errors.New("no static loop-in candidate") +) + +// Compile-time assertion that static loop-in suggestions satisfy the shared +// swap suggestion interface. +var _ swapSuggestion = (*staticLoopInSwapSuggestion)(nil) + +// PreparedStaticLoopIn contains the dry-run data that liquidity needs in order +// to represent and account for a static loop-in suggestion. +type PreparedStaticLoopIn struct { + // Request is the fully specified loop-in request that should be used if + // the suggestion is later dispatched. + Request loop.StaticAddressLoopInRequest + + // NumDeposits is the number of deposits selected for the request. We + // keep this separate so that fee estimation does not need to inspect + // any static-address-specific types. + NumDeposits int + + // HasChange indicates whether the selected deposits would create + // change. The initial autoloop implementation always keeps this false, + // but the flag is included so that future partial-selection modes can + // reuse the same accounting path safely. + HasChange bool +} + +// StaticLoopInDispatchResult contains the values that autoloop logs after a +// static loop-in is dispatched. +type StaticLoopInDispatchResult struct { + // SwapHash is the static loop-in swap identifier. + SwapHash lntypes.Hash +} + // StaticLoopInInfo contains the persisted data that liquidity needs for budget // accounting and peer traffic tracking. type StaticLoopInInfo struct { @@ -53,6 +95,92 @@ type StaticLoopInInfo struct { HasChange bool } +// staticLoopInSwapSuggestion is the suggested representation of a static loop +// in request. +type staticLoopInSwapSuggestion struct { + // request is the request that will be dispatched if autoloop executes + // the suggestion. + request loop.StaticAddressLoopInRequest + + // numDeposits is the number of deposits consumed by the swap. This + // feeds the conservative HTLC fee estimate used for budget filtering. + numDeposits int + + // hasChange indicates whether the suggestion would create change. + hasChange bool +} + +// staticLoopInCandidate is the pre-preparation representation of a static +// loop-in rule match. It carries the peer target and desired amount so the +// planner can sort candidates before allocating concrete deposits. +type staticLoopInCandidate struct { + // peer is the target peer for the loop-in. + peer route.Vertex + + // minAmount is the minimum swap size that the eventual full-deposit + // selection must still satisfy after any allowed undershoot. + minAmount btcutil.Amount + + // amountHint is the maximum amount that the planner should try to cover + // with full-deposit static selection. + amountHint btcutil.Amount + + // channelSet carries the peer aggregate's channels so disqualification + // reasons can still be attached consistently during later filtering. + channelSet []lnwire.ShortChannelID +} + +// amount returns the desired amount for the candidate. +func (s *staticLoopInCandidate) amount() btcutil.Amount { + return s.amountHint +} + +// channels returns the channels that belong to the target peer aggregate. +func (s *staticLoopInCandidate) channels() []lnwire.ShortChannelID { + return s.channelSet +} + +// peers returns the single target peer for the candidate. +func (s *staticLoopInCandidate) peers( + _ map[uint64]route.Vertex) []route.Vertex { + + return []route.Vertex{s.peer} +} + +// amount returns the selected swap amount for the suggestion. +func (s *staticLoopInSwapSuggestion) amount() btcutil.Amount { + return s.request.SelectedAmount +} + +// fees returns the worst-case fee estimate for a static loop-in suggestion. +func (s *staticLoopInSwapSuggestion) fees() btcutil.Amount { + // The actual HTLC fee rate is only known once the server returns the + // signed HTLC packages during initiation. For dry-run planning we use + // the same conservative fee-rate constant that loop-in sweep budgeting + // already uses so that static suggestions do not undercount timeout + // risk. + return staticLoopInWorstCaseFees( + s.numDeposits, s.hasChange, s.request.MaxSwapFee, + defaultLoopInSweepFee, defaultLoopInSweepFee, + ) +} + +// channels returns no channels because loop-in rules are peer-scoped. +func (s *staticLoopInSwapSuggestion) channels() []lnwire.ShortChannelID { + return nil +} + +// peers returns the peer that the static loop-in suggestion targets. +func (s *staticLoopInSwapSuggestion) peers( + _ map[uint64]route.Vertex) []route.Vertex { + + if s.request.LastHop == nil { + return nil + } + + return []route.Vertex{*s.request.LastHop} +} + // 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, @@ -120,3 +248,52 @@ func staticLoopInHtlcWeight(numDeposits int, return estimator.Weight() } + +// staticLoopInFeeLimit checks a static loop-in candidate against the active +// fee policy using the static swap's own worst-case fee model instead of the +// legacy wallet-funded loop-in assumptions. +func staticLoopInFeeLimit(feeLimit FeeLimit, amount, swapFee btcutil.Amount, + numDeposits int, hasChange bool) error { + + switch limit := feeLimit.(type) { + case *FeeCategoryLimit: + maxServerFee := ppmToSat(amount, limit.MaximumSwapFeePPM) + if swapFee > maxServerFee { + return newReasonError(ReasonSwapFee) + } + + // We do not know the final HTLC fee rate until the server + // returns concrete HTLC packages during initiation, so the + // planner has to reuse the same conservative default that + // dry-run budget filtering already uses. + onchainFees := staticLoopInOnchainFee( + numDeposits, hasChange, defaultLoopInSweepFee, + defaultLoopInSweepFee, + ) + + if onchainFees > limit.MaximumMinerFee { + return newReasonError(ReasonMinerFee) + } + + return nil + + case *FeePortion: + totalFeeSpend := ppmToSat(amount, limit.PartsPerMillion) + if swapFee > totalFeeSpend { + return newReasonError(ReasonSwapFee) + } + + fees := staticLoopInWorstCaseFees( + numDeposits, hasChange, swapFee, defaultLoopInSweepFee, + defaultLoopInSweepFee, + ) + if fees > totalFeeSpend { + return newReasonError(ReasonFeePPMInsufficient) + } + + return nil + + default: + return fmt.Errorf("unknown fee limit: %T", feeLimit) + } +} diff --git a/liquidity/static_loopin_test.go b/liquidity/static_loopin_test.go index 12b227c46..55860cd51 100644 --- a/liquidity/static_loopin_test.go +++ b/liquidity/static_loopin_test.go @@ -6,9 +6,13 @@ import ( "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" ) @@ -274,3 +278,197 @@ func TestCurrentSwapTrafficStatic(t *testing.T) { 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.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.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() + + 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) +} diff --git a/loopd/utils.go b/loopd/utils.go index 5a98ae97a..a73434caa 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -2,6 +2,7 @@ package loopd import ( "context" + "errors" "fmt" "slices" @@ -17,6 +18,7 @@ import ( "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/sweepbatcher" "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/ticker" ) @@ -161,6 +163,58 @@ func getLiquidityManager(client *loop.Client, return result, nil } + prepareStaticLoopIn := func(ctx context.Context, peer route.Vertex, + minAmount, amount btcutil.Amount, label, initiator string, + excludedOutpoints []string) (*liquidity.PreparedStaticLoopIn, + error) { + + if staticLoopInManager == nil { + return nil, errors.New( + "static loop in manager unavailable", + ) + } + + request, numDeposits, hasChange, err := + staticLoopInManager.PrepareAutoloopLoopIn( + ctx, peer, minAmount, amount, label, + initiator, excludedOutpoints, + ) + if errors.Is(err, loopin.ErrNoAutoloopCandidate) { + return nil, liquidity.ErrNoStaticLoopInCandidate + } + if err != nil { + return nil, err + } + + return &liquidity.PreparedStaticLoopIn{ + Request: *request, + NumDeposits: numDeposits, + HasChange: hasChange, + }, nil + } + + staticLoopIn := func(ctx context.Context, + request *loop.StaticAddressLoopInRequest) ( + *liquidity.StaticLoopInDispatchResult, error) { + + if staticLoopInManager == nil { + return nil, errors.New( + "static loop in manager unavailable", + ) + } + + swapInfo, err := staticLoopInManager.DeliverLoopInRequest( + ctx, request, + ) + if err != nil { + return nil, err + } + + return &liquidity.StaticLoopInDispatchResult{ + SwapHash: swapInfo.SwapHash, + }, nil + } + mngrCfg := &liquidity.Config{ AutoloopTicker: ticker.NewForce(liquidity.DefaultAutoloopTicker), LoopOut: client.LoopOut, @@ -196,6 +250,8 @@ func getLiquidityManager(client *loop.Client, GetLoopOut: client.Store.FetchLoopOutSwap, ListLoopIn: client.Store.FetchLoopInSwaps, ListStaticLoopIn: listStaticLoopIn, + PrepareStaticLoopIn: prepareStaticLoopIn, + StaticLoopIn: staticLoopIn, LoopInTerms: client.LoopInTerms, LoopOutTerms: client.LoopOutTerms, GetAssetPrice: client.AssetClient.GetAssetPrice, From 80f146783615174edbdc3c811d42c9c40f311020 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 01:17:30 -0500 Subject: [PATCH 08/10] looprpc: map custom channel reason Static autoloop testing surfaced a SuggestSwaps failure when the planner disqualified a custom asset channel. Add the missing AutoReason enum value and handle ReasonCustomChannelData. --- liquidity/reasons.go | 3 +++ loopd/swapclient_server.go | 3 +++ loopd/swapclient_server_test.go | 10 ++++++++++ looprpc/client.pb.go | 10 ++++++++-- looprpc/client.proto | 6 ++++++ looprpc/client.swagger.json | 5 +++-- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/liquidity/reasons.go b/liquidity/reasons.go index 5f7cf618d..a186e6f85 100644 --- a/liquidity/reasons.go +++ b/liquidity/reasons.go @@ -128,6 +128,9 @@ func (r Reason) String() string { case ReasonLoopInUnreachable: return "loop in unreachable" + case ReasonCustomChannelData: + return "custom channel data" + case ReasonStaticLoopInNoCandidate: return "no static loop-in candidate" diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 043538230..f26f1d914 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2442,6 +2442,9 @@ func rpcAutoloopReason(reason liquidity.Reason) (looprpc.AutoReason, error) { return looprpc.AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE, nil + case liquidity.ReasonCustomChannelData: + return looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, nil + default: return 0, fmt.Errorf("unknown autoloop reason: %v", reason) } diff --git a/loopd/swapclient_server_test.go b/loopd/swapclient_server_test.go index f06c9d07e..65494664f 100644 --- a/loopd/swapclient_server_test.go +++ b/loopd/swapclient_server_test.go @@ -294,6 +294,16 @@ func TestRPCAutoloopReasonStaticLoopInNoCandidate(t *testing.T) { ) } +// TestRPCAutoloopReasonCustomChannelData verifies that custom-channel +// disqualifications are exposed over rpc instead of failing the whole dry run. +func TestRPCAutoloopReasonCustomChannelData(t *testing.T) { + reason, err := rpcAutoloopReason(liquidity.ReasonCustomChannelData) + require.NoError(t, err) + require.Equal( + t, looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, reason, + ) +} + // TestSwapClientServerStopDaemon ensures that calling StopDaemon triggers the // daemon shutdown. func TestSwapClientServerStopDaemon(t *testing.T) { diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 27b8b0fdf..d0cf44f17 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -427,6 +427,9 @@ const ( // No static loop-in candidate indicates that static loop-in autoloop was // selected, but no full-deposit static candidate fit the rule. AutoReason_AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE AutoReason = 14 + // Custom channel data indicates that the target channel carries custom + // channel data and is excluded from the standard autoloop planner. + AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA AutoReason = 15 ) // Enum value maps for AutoReason. @@ -447,6 +450,7 @@ var ( 12: "AUTO_REASON_BUDGET_INSUFFICIENT", 13: "AUTO_REASON_FEE_INSUFFICIENT", 14: "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE", + 15: "AUTO_REASON_CUSTOM_CHANNEL_DATA", } AutoReason_value = map[string]int32{ "AUTO_REASON_UNKNOWN": 0, @@ -464,6 +468,7 @@ var ( "AUTO_REASON_BUDGET_INSUFFICIENT": 12, "AUTO_REASON_FEE_INSUFFICIENT": 13, "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE": 14, + "AUTO_REASON_CUSTOM_CHANNEL_DATA": 15, } ) @@ -7059,7 +7064,7 @@ const file_client_proto_rawDesc = "" + "\x1dLOOP_IN_SOURCE_STATIC_ADDRESS\x10\x01*/\n" + "\x11LiquidityRuleType\x12\v\n" + "\aUNKNOWN\x10\x00\x12\r\n" + - "\tTHRESHOLD\x10\x01*\xd3\x03\n" + + "\tTHRESHOLD\x10\x01*\xf8\x03\n" + "\n" + "AutoReason\x12\x17\n" + "\x13AUTO_REASON_UNKNOWN\x10\x00\x12\"\n" + @@ -7077,7 +7082,8 @@ const file_client_proto_rawDesc = "" + "\x18AUTO_REASON_LIQUIDITY_OK\x10\v\x12#\n" + "\x1fAUTO_REASON_BUDGET_INSUFFICIENT\x10\f\x12 \n" + "\x1cAUTO_REASON_FEE_INSUFFICIENT\x10\r\x12+\n" + - "'AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE\x10\x0e*\x88\x02\n" + + "'AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE\x10\x0e\x12#\n" + + "\x1fAUTO_REASON_CUSTOM_CHANNEL_DATA\x10\x0f*\x88\x02\n" + "\fDepositState\x12\x11\n" + "\rUNKNOWN_STATE\x10\x00\x12\r\n" + "\tDEPOSITED\x10\x01\x12\x0f\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index 7ab3444c2..52b9fcbb0 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1549,6 +1549,12 @@ enum AutoReason { selected, but no full-deposit static candidate fit the rule. */ AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE = 14; + + /* + Custom channel data indicates that the target channel carries custom + channel data and is excluded from the standard autoloop planner. + */ + AUTO_REASON_CUSTOM_CHANNEL_DATA = 15; } message Disqualified { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 9a3a4bca3..e336cb7dc 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1544,10 +1544,11 @@ "AUTO_REASON_LIQUIDITY_OK", "AUTO_REASON_BUDGET_INSUFFICIENT", "AUTO_REASON_FEE_INSUFFICIENT", - "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE" + "AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE", + "AUTO_REASON_CUSTOM_CHANNEL_DATA" ], "default": "AUTO_REASON_UNKNOWN", - "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume.\n - AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE: No static loop-in candidate indicates that static loop-in autoloop was\nselected, but no full-deposit static candidate fit the rule." + "description": " - AUTO_REASON_BUDGET_NOT_STARTED: Budget not started indicates that we do not recommend any swaps because\nthe start time for our budget has not arrived yet.\n - AUTO_REASON_SWEEP_FEES: Sweep fees indicates that the estimated fees to sweep swaps are too high\nright now.\n - AUTO_REASON_BUDGET_ELAPSED: Budget elapsed indicates that the autoloop budget for the period has been\nelapsed.\n - AUTO_REASON_IN_FLIGHT: In flight indicates that the limit on in-flight automatically dispatched\nswaps has already been reached.\n - AUTO_REASON_SWAP_FEE: Swap fee indicates that the server fee for a specific swap is too high.\n - AUTO_REASON_MINER_FEE: Miner fee indicates that the miner fee for a specific swap is to high.\n - AUTO_REASON_PREPAY: Prepay indicates that the prepay fee for a specific swap is too high.\n - AUTO_REASON_FAILURE_BACKOFF: Failure backoff indicates that a swap has recently failed for this target,\nand the backoff period has not yet passed.\n - AUTO_REASON_LOOP_OUT: Loop out indicates that a loop out swap is currently utilizing the channel,\nso it is not eligible.\n - AUTO_REASON_LOOP_IN: Loop In indicates that a loop in swap is currently in flight for the peer,\nso it is not eligible.\n - AUTO_REASON_LIQUIDITY_OK: Liquidity ok indicates that a target meets the liquidity balance expressed\nin its rule, so no swap is needed.\n - AUTO_REASON_BUDGET_INSUFFICIENT: Budget insufficient indicates that we cannot perform a swap because we do\nnot have enough pending budget available. This differs from budget elapsed,\nbecause we still have some budget available, but we have allocated it to\nother swaps.\n - AUTO_REASON_FEE_INSUFFICIENT: Fee insufficient indicates that the fee estimate for a swap is higher than\nthe portion of total swap amount that we allow fees to consume.\n - AUTO_REASON_STATIC_LOOP_IN_NO_CANDIDATE: No static loop-in candidate indicates that static loop-in autoloop was\nselected, but no full-deposit static candidate fit the rule.\n - AUTO_REASON_CUSTOM_CHANNEL_DATA: Custom channel data indicates that the target channel carries custom\nchannel data and is excluded from the standard autoloop planner." }, "looprpcClientReservation": { "type": "object", From 51e5a717afa32469431bfd1b3d936c7568aea7d6 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 03:46:57 -0500 Subject: [PATCH 09/10] docs: update (autoloop supports static) --- docs/loop.1 | 3 +++ docs/loop.md | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/loop.1 b/docs/loop.1 index 7d767cdff..647a6ffe3 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -359,6 +359,9 @@ update the parameters set for the liquidity manager .PP \fB--localbalancesat\fP="": the target size of total local balance in satoshis, used by easy autoloop. (default: 0) +.PP +\fB--loopinsource\fP="": the loop-in source to use for autoloop rules: wallet or static-address. + .PP \fB--maxamt\fP="": the maximum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0) diff --git a/docs/loop.md b/docs/loop.md index 3a847e96c..24a09badd 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -371,6 +371,7 @@ The following flags are supported: | `--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` | | `--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 | | `--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` | | `--easyautoloop_excludepeer="…"` | list of peer pubkeys (hex) to exclude from easy autoloop channel selection; repeat --easyautoloop_excludepeer for multiple peers | string | `[]` | From 6f82494937654523d1b3dba5c1d588fa57e17e5f Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 14 May 2026 01:20:54 -0500 Subject: [PATCH 10/10] staticaddr: use dp autoloop selector Replace the recursive full-deposit autoloop selector with a bounded-memory DP implementation in staticaddr/loopin/autoloop_dp.go. The new selector keeps the existing no-change semantics, first finds the best reachable total, then applies the 25 percent band rule so earlier-expiring deposits can win inside that near-optimal range. The DP table is capped at 128 MiB and keeps exact satoshi sums alongside compressed bucket weights, so planning stays memory-bounded without allowing oversized candidates. The compressed weighting now rounds down with a minimum of one bucket, which avoids rejecting valid sums after multiple per-deposit rounding steps while leaving the exact-sum check as the real safety boundary. --- staticaddr/loopin/autoloop.go | 164 +------- staticaddr/loopin/autoloop_dp.go | 535 ++++++++++++++++++++++++++ staticaddr/loopin/autoloop_dp_test.go | 425 ++++++++++++++++++++ staticaddr/loopin/autoloop_test.go | 55 ++- 4 files changed, 1014 insertions(+), 165 deletions(-) create mode 100644 staticaddr/loopin/autoloop_dp.go create mode 100644 staticaddr/loopin/autoloop_dp_test.go diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go index 851941e82..337d73e15 100644 --- a/staticaddr/loopin/autoloop.go +++ b/staticaddr/loopin/autoloop.go @@ -3,8 +3,6 @@ package loopin import ( "context" "errors" - "slices" - "sort" "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/loop" @@ -91,164 +89,8 @@ func selectNoChangeDeposits(maxAmount, minAmount btcutil.Amount, unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32, excludedOutpoints map[string]struct{}) ([]*deposit.Deposit, error) { - // Filter out deposits that cannot safely participate in a loop-in or - // were already allocated to a larger suggestion earlier in the same - // planning pass. - deposits := make([]*deposit.Deposit, 0, len(unfilteredDeposits)) - for _, deposit := range unfilteredDeposits { - if _, ok := excludedOutpoints[deposit.OutPoint.String()]; ok { - continue - } - - swappable := IsSwappable( - uint32(deposit.ConfirmationHeight), blockHeight, - csvExpiry, - ) - if !swappable { - continue - } - - if deposit.Value > maxAmount { - continue - } - - deposits = append(deposits, deposit) - } - - if len(deposits) == 0 { - return nil, ErrNoAutoloopCandidate - } - - // Sort by value so the search finds large feasible totals early. The - // expiry tie-break keeps equal-value deposits deterministic and helps - // the later candidate comparison prefer sooner-expiring funds. - sort.SliceStable(deposits, func(i, j int) bool { - if deposits[i].Value == deposits[j].Value { - return deposits[i].ConfirmationHeight < - deposits[j].ConfirmationHeight - } - - return deposits[i].Value > deposits[j].Value - }) - - // Precompute a suffix sum so branches that cannot possibly beat the - // current best total can be pruned before exploring the expensive part - // of the search tree. - suffixSums := make([]btcutil.Amount, len(deposits)+1) - for i := len(deposits) - 1; i >= 0; i-- { - suffixSums[i] = suffixSums[i+1] + deposits[i].Value - } - - var ( - bestSelection []int - bestTotal btcutil.Amount + return selectNoChangeDepositsWithMemoryBudget( + maxAmount, minAmount, unfilteredDeposits, csvExpiry, + blockHeight, excludedOutpoints, autoloopDPMaxMemoryBytes, ) - - // betterSelection applies the full-deposit ordering: - // 1. highest total not exceeding the target - // 2. fewer deposits - // 3. earlier-expiring deposits - betterSelection := func(candidate []int, total btcutil.Amount) bool { - switch { - case total > bestTotal: - return true - - case total < bestTotal: - return false - - case bestSelection == nil: - return true - - case len(candidate) < len(bestSelection): - return true - - case len(candidate) > len(bestSelection): - return false - } - - // Use signed arithmetic here so an expired deposit cannot wrap - // the residual-life comparison if height updates race the - // earlier swappability filter. - left := make([]int64, len(candidate)) - for i, index := range candidate { - left[i] = deposits[index].ConfirmationHeight + - int64(csvExpiry) - int64(blockHeight) - } - - right := make([]int64, len(bestSelection)) - for i, index := range bestSelection { - right[i] = deposits[index].ConfirmationHeight + - int64(csvExpiry) - int64(blockHeight) - } - - slices.Sort(left) - slices.Sort(right) - - for i := range left { - if left[i] == right[i] { - continue - } - - return left[i] < right[i] - } - - return false - } - - // search explores include/exclude choices. The branch-and-bound checks - // are intentionally conservative: they only prune when no combination - // below the current node can beat the best known total or tie it with a - // smaller deposit count. - var search func(index int, total btcutil.Amount, selected []int) - search = func(index int, total btcutil.Amount, selected []int) { - if total > maxAmount { - return - } - - if total >= minAmount && betterSelection(selected, total) { - bestTotal = total - bestSelection = append([]int(nil), selected...) - } - - if index == len(deposits) { - return - } - - maxReachable := total + suffixSums[index] - if maxReachable < bestTotal { - return - } - - if maxReachable == bestTotal && bestSelection != nil && - len(selected) >= len(bestSelection) { - - return - } - - // The include branch must not reuse selected's backing array. - // Otherwise a later append can leak into the exclude branch - // when the slice still has spare capacity. - selectedWithIndex := make([]int, len(selected)+1) - copy(selectedWithIndex, selected) - selectedWithIndex[len(selected)] = index - - search( - index+1, total+deposits[index].Value, - selectedWithIndex, - ) - search(index+1, total, selected) - } - - search(0, 0, nil) - - if len(bestSelection) == 0 { - return nil, ErrNoAutoloopCandidate - } - - selectedDeposits := make([]*deposit.Deposit, 0, len(bestSelection)) - for _, index := range bestSelection { - selectedDeposits = append(selectedDeposits, deposits[index]) - } - - return selectedDeposits, nil } diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go new file mode 100644 index 000000000..b394d227d --- /dev/null +++ b/staticaddr/loopin/autoloop_dp.go @@ -0,0 +1,535 @@ +package loopin + +import ( + "errors" + "math/bits" + "sort" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/staticaddr/deposit" +) + +const ( + // autoloopDPMaxMemoryBytes caps the selector's working set. The + // selector compresses the sum space when needed so one planning tick + // cannot consume unbounded memory just because a node has many static + // deposits. + autoloopDPMaxMemoryBytes = 128 * 1024 * 1024 + + // autoloopDPStateOverheadBytes approximates the per-bucket cost outside + // of the bitset itself. The exact sum and count slices account for the + // logical state, and the extra slack keeps the sizing conservative so + // the selector stays below the intended memory budget in practice. + autoloopDPStateOverheadBytes = 16 +) + +var ( + // errAutoloopDPMemoryBudgetTooSmall is returned when even the coarsest + // two-bucket table would exceed the configured memory budget. This is a + // structural limitation of the bounded-memory representation, not a + // liquidity constraint, so callers should not collapse it into + // ErrNoAutoloopCandidate. + errAutoloopDPMemoryBudgetTooSmall = errors.New( + "autoloop dp memory budget too small", + ) +) + +// autoloopCandidateDeposit carries the precomputed metadata the DP needs to +// make deterministic comparisons. +type autoloopCandidateDeposit struct { + // deposit is the original static-address deposit that may be selected. + deposit *deposit.Deposit + + // residualLife is the remaining lifetime of the deposit in blocks once + // the current height is taken into account. + residualLife int64 + + // outpoint is cached so deterministic ordering does not keep rebuilding + // the string form during sort comparisons. + outpoint string +} + +// autoloopDPTable stores one representative subset for each compressed sum +// bucket. Each representative carries its full bitset so later updates can +// compare candidates without relying on mutable predecessor buckets. +// +// This is intentionally heavier than a predecessor-only table. A simpler +// parent-pointer representation would be smaller per bucket, but it becomes +// incorrect once a source bucket is overwritten by a later update because +// already-derived states would silently change their parent chains. +// +// When the selector compresses the sum space, several exact sums can share one +// bucket. The build phase keeps only one representative for that bucket and +// prefers the larger exact sum before the final band scan considers expiry. +// That makes the compressed path approximate: an earlier-expiring smaller-sum +// subset can be hidden by a larger-sum subset in the same bucket. The default +// 128 MiB budget keeps realistic production inputs at step = 1, so this trade- +// off only matters when tests or future callers intentionally lower the memory +// budget. +type autoloopDPTable struct { + // wordsPerState is the number of 64-bit words needed to represent one + // deposit-selection bitset. + wordsPerState int + + // exactSums stores the real satoshi sum of the representative subset in + // each bucket. The selector never trusts the compressed bucket weight + // for range checks because the DP is allowed to scale the sum space. + exactSums []btcutil.Amount + + // counts stores the number of selected deposits for each bucket. A + // value of -1 marks an unreachable bucket. + counts []int32 + + // selections stores one flattened bitset per bucket. The bitset is the + // self-contained reconstruction data for the representative subset. + selections []uint64 +} + +// selectNoChangeDepositsWithMemoryBudget runs the bounded-memory selector with +// an explicit budget. Tests use this entry point to force the scaling path and +// to exercise hard budget failures deterministically. +func selectNoChangeDepositsWithMemoryBudget(maxAmount, minAmount btcutil.Amount, + unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32, + excludedOutpoints map[string]struct{}, + maxMemoryBytes int) ([]*deposit.Deposit, error) { + + eligibleDeposits, eligibleTotal := filterAutoloopCandidateDeposits( + maxAmount, unfilteredDeposits, csvExpiry, blockHeight, + excludedOutpoints, + ) + if len(eligibleDeposits) == 0 || eligibleTotal < minAmount { + return nil, ErrNoAutoloopCandidate + } + + step, bucketCount, err := autoloopDPSizing( + maxAmount, len(eligibleDeposits), maxMemoryBytes, + ) + if err != nil { + return nil, err + } + + table := newAutoloopDPTable(bucketCount, len(eligibleDeposits)) + + // The empty subset is the base state for all later transitions. + table.counts[0] = 0 + + for depositIndex, candidateDeposit := range eligibleDeposits { + weight := autoloopDPBucketWeight( + candidateDeposit.deposit.Value, step, + ) + + // Descending updates preserve the 0-1 constraint: every deposit + // is either present once in a candidate or not at all. + start := bucketCount - weight - 1 + for sourceBucket := start; sourceBucket >= 0; sourceBucket-- { + if !table.isReachable(sourceBucket) { + continue + } + + destBucket := sourceBucket + weight + candidateSum := table.exactSums[sourceBucket] + + candidateDeposit.deposit.Value + + if candidateSum > maxAmount { + continue + } + + beats := table.candidateBeatsState( + sourceBucket, destBucket, depositIndex, + candidateSum, eligibleDeposits, + ) + if !beats { + continue + } + + table.copyStateFromSource( + destBucket, sourceBucket, depositIndex, + candidateSum, + ) + } + } + + bestTotal := btcutil.Amount(-1) + for bucket := 1; bucket < bucketCount; bucket++ { + if !table.isReachable(bucket) { + continue + } + + exactSum := table.exactSums[bucket] + if exactSum < minAmount || exactSum > maxAmount { + continue + } + + if exactSum > bestTotal { + bestTotal = exactSum + } + } + + if bestTotal < 0 { + return nil, ErrNoAutoloopCandidate + } + + // The band lets expiry management influence the final choice, but only + // after the selector first learns the best liquidity amount that the + // compressed DP table can achieve. The slack gives back up to 25 percent + // of the gain above minAmount, not 25 percent of bestTotal itself. + slack := (bestTotal - minAmount) / 4 + bandFloor := bestTotal - slack + + bestBucket := -1 + for bucket := 1; bucket < bucketCount; bucket++ { + if !table.isReachable(bucket) { + continue + } + + exactSum := table.exactSums[bucket] + if exactSum < bandFloor || exactSum > bestTotal { + continue + } + + if bestBucket == -1 { + bestBucket = bucket + continue + } + + beats := table.stateBeatsStateWithinBand( + bucket, bestBucket, eligibleDeposits, + ) + if beats { + bestBucket = bucket + } + } + + if bestBucket == -1 { + return nil, ErrNoAutoloopCandidate + } + + selectedIndices := table.selectedIndices(bestBucket) + selectedDeposits := make([]*deposit.Deposit, 0, len(selectedIndices)) + for _, index := range selectedIndices { + selectedDeposits = append( + selectedDeposits, eligibleDeposits[index].deposit, + ) + } + + return selectedDeposits, nil +} + +// filterAutoloopCandidateDeposits removes deposits that can never participate +// in a full-deposit static autoloop suggestion and sorts the remainder in the +// order used by the expiry comparisons. +func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount, + unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32, + excludedOutpoints map[string]struct{}) ( + []autoloopCandidateDeposit, btcutil.Amount) { + + eligibleDeposits := make( + []autoloopCandidateDeposit, 0, len(unfilteredDeposits), + ) + var eligibleTotal btcutil.Amount + + for _, candidateDeposit := range unfilteredDeposits { + outpoint := candidateDeposit.OutPoint.String() + if _, ok := excludedOutpoints[outpoint]; ok { + continue + } + + swappable := IsSwappable( + uint32(candidateDeposit.ConfirmationHeight), + blockHeight, csvExpiry, + ) + if !swappable { + continue + } + + if candidateDeposit.Value > maxAmount { + continue + } + + residualLife := candidateDeposit.ConfirmationHeight + + int64(csvExpiry) - int64(blockHeight) + + eligibleDeposits = append( + eligibleDeposits, autoloopCandidateDeposit{ + deposit: candidateDeposit, + residualLife: residualLife, + outpoint: outpoint, + }, + ) + eligibleTotal += candidateDeposit.Value + } + + // The DP compares reconstructed residual-life sequences directly. + // Sorting deposits by residual life first keeps those later comparisons + // exact and deterministic without inventing a scalar "urgency score". + sort.Slice(eligibleDeposits, func(i, j int) bool { + left := eligibleDeposits[i] + right := eligibleDeposits[j] + + switch { + case left.residualLife != right.residualLife: + return left.residualLife < right.residualLife + + case left.deposit.Value != right.deposit.Value: + return left.deposit.Value > right.deposit.Value + } + + return left.outpoint < right.outpoint + }) + + return eligibleDeposits, eligibleTotal +} + +// autoloopDPSizing chooses the smallest bucket step that keeps the compressed +// table within the configured memory budget. +func autoloopDPSizing(maxAmount btcutil.Amount, depositCount, + maxMemoryBytes int) (btcutil.Amount, int, error) { + + wordsPerState := autoloopDPWordsPerState(depositCount) + stateBytes := wordsPerState*8 + autoloopDPStateOverheadBytes + maxBuckets := maxMemoryBytes / stateBytes + + // The selector needs at least bucket zero plus one positive bucket. + if maxBuckets < 2 { + return 0, 0, errAutoloopDPMemoryBudgetTooSmall + } + + step := ceilAmountDiv(maxAmount, btcutil.Amount(maxBuckets-1)) + if step < 1 { + step = 1 + } + + bucketCount := int(ceilAmountDiv(maxAmount, step)) + 1 + + return step, bucketCount, nil +} + +// autoloopDPWordsPerState returns the number of 64-bit words needed to encode +// one subset bitset for the current deposit count. +func autoloopDPWordsPerState(depositCount int) int { + return (depositCount + 63) / 64 +} + +// autoloopDPBucketWeight compresses a deposit value into one DP bucket weight. +// +// The table rounds down, but never below one bucket. Rounding up each deposit +// would accidentally reject some valid exact sums once several per-item +// round-up errors accumulate. The exact-sum guard remains the real safety +// boundary: compressed weights only decide which representative states are kept +// in memory, never whether a candidate is allowed to exceed maxAmount. +func autoloopDPBucketWeight(value, step btcutil.Amount) int { + weight := int(value / step) + if weight == 0 { + return 1 + } + + return weight +} + +// ceilAmountDiv performs positive ceiling division for amount sizing. +func ceilAmountDiv(numerator, denominator btcutil.Amount) btcutil.Amount { + if numerator <= 0 { + return 0 + } + + return (numerator + denominator - 1) / denominator +} + +// newAutoloopDPTable allocates the bounded-memory DP table. +func newAutoloopDPTable(bucketCount, depositCount int) *autoloopDPTable { + wordsPerState := autoloopDPWordsPerState(depositCount) + + counts := make([]int32, bucketCount) + for i := range counts { + counts[i] = -1 + } + + return &autoloopDPTable{ + wordsPerState: wordsPerState, + exactSums: make([]btcutil.Amount, bucketCount), + counts: counts, + selections: make([]uint64, bucketCount*wordsPerState), + } +} + +// isReachable reports whether a bucket currently has a representative subset. +func (t *autoloopDPTable) isReachable(bucket int) bool { + return t.counts[bucket] >= 0 +} + +// stateWords returns the flattened bitset slice for one bucket. +func (t *autoloopDPTable) stateWords(bucket int) []uint64 { + start := bucket * t.wordsPerState + end := start + t.wordsPerState + + return t.selections[start:end] +} + +// copyStateFromSource writes a winning candidate into the destination bucket. +func (t *autoloopDPTable) copyStateFromSource(destBucket, sourceBucket, + depositIndex int, exactSum btcutil.Amount) { + + destWords := t.stateWords(destBucket) + sourceWords := t.stateWords(sourceBucket) + copy(destWords, sourceWords) + + wordIndex := depositIndex / 64 + bitIndex := uint(depositIndex % 64) + destWords[wordIndex] |= uint64(1) << bitIndex + + t.exactSums[destBucket] = exactSum + t.counts[destBucket] = t.counts[sourceBucket] + 1 +} + +// candidateBeatsState reports whether the candidate obtained by extending the +// source bucket with one deposit should replace the destination bucket. +func (t *autoloopDPTable) candidateBeatsState(sourceBucket, destBucket, + depositIndex int, candidateSum btcutil.Amount, + deposits []autoloopCandidateDeposit) bool { + + if !t.isReachable(destBucket) { + return true + } + + existingSum := t.exactSums[destBucket] + if candidateSum != existingSum { + return candidateSum > existingSum + } + + candidateCount := int(t.counts[sourceBucket]) + 1 + existingCount := int(t.counts[destBucket]) + if candidateCount != existingCount { + return candidateCount < existingCount + } + + // Only exact-sum and exact-count ties fall through to expiry + // comparison. That keeps the expensive residual-life reconstruction + // off the hot path. + return t.candidateEarlierThanState( + sourceBucket, destBucket, depositIndex, deposits, + ) +} + +// candidateEarlierThanState compares the candidate residual-life sequence to +// the existing destination sequence. +func (t *autoloopDPTable) candidateEarlierThanState(sourceBucket, destBucket, + depositIndex int, deposits []autoloopCandidateDeposit) bool { + + candidateResidualLives := t.candidateResidualLives( + sourceBucket, depositIndex, deposits, + ) + existingResidualLives := t.stateResidualLives(destBucket, deposits) + + return compareResidualLifeSequences( + candidateResidualLives, existingResidualLives, + ) < 0 +} + +// stateBeatsStateWithinBand applies the final band-local ordering: +// 1. earlier-expiring deposits +// 2. larger exact total +// 3. fewer deposits +func (t *autoloopDPTable) stateBeatsStateWithinBand(leftBucket, rightBucket int, + deposits []autoloopCandidateDeposit) bool { + + leftResidualLives := t.stateResidualLives(leftBucket, deposits) + rightResidualLives := t.stateResidualLives(rightBucket, deposits) + + cmp := compareResidualLifeSequences( + leftResidualLives, rightResidualLives, + ) + switch cmp { + case -1: + return true + + case 1: + return false + } + + leftSum := t.exactSums[leftBucket] + rightSum := t.exactSums[rightBucket] + if leftSum != rightSum { + return leftSum > rightSum + } + + return t.counts[leftBucket] < t.counts[rightBucket] +} + +// selectedIndices reconstructs the selected deposit indices for a bucket in +// sorted order. The caller must pass a reachable bucket. +func (t *autoloopDPTable) selectedIndices(bucket int) []int { + count := int(t.counts[bucket]) + if count < 0 { + panic("selectedIndices called on unreachable bucket") + } + + selectedIndices := make([]int, 0, count) + for wordIndex, word := range t.stateWords(bucket) { + for word != 0 { + bitIndex := bits.TrailingZeros64(word) + selectedIndices = append( + selectedIndices, wordIndex*64+bitIndex, + ) + word &^= uint64(1) << uint(bitIndex) + } + } + + return selectedIndices +} + +// candidateResidualLives reconstructs the candidate residual-life sequence. +// The current deposit index is always larger than every index already present +// in the source bucket, so appending preserves the sorted order. +func (t *autoloopDPTable) candidateResidualLives(sourceBucket, depositIndex int, + deposits []autoloopCandidateDeposit) []int64 { + + residualLives := t.stateResidualLives(sourceBucket, deposits) + residualLives = append( + residualLives, deposits[depositIndex].residualLife, + ) + + return residualLives +} + +// stateResidualLives reconstructs the sorted residual-life sequence for a +// bucket. +func (t *autoloopDPTable) stateResidualLives(bucket int, + deposits []autoloopCandidateDeposit) []int64 { + + selectedIndices := t.selectedIndices(bucket) + residualLives := make([]int64, 0, len(selectedIndices)) + for _, index := range selectedIndices { + residualLives = append( + residualLives, deposits[index].residualLife, + ) + } + + return residualLives +} + +// compareResidualLifeSequences compares two sorted residual-life sequences. +// +// A smaller residual-life value means the deposit expires sooner and is thus +// more urgent to consume. The comparison intentionally stops at the shorter +// length: if one sequence is a strict prefix of the other, expiry alone does +// not provide a principled winner and the caller falls back to amount and +// deposit-count tie-breakers instead of inventing an arbitrary preference for +// the longer or shorter set. +func compareResidualLifeSequences(left, right []int64) int { + limit := len(left) + if len(right) < limit { + limit = len(right) + } + + for i := 0; i < limit; i++ { + switch { + case left[i] < right[i]: + return -1 + + case left[i] > right[i]: + return 1 + } + } + + return 0 +} diff --git a/staticaddr/loopin/autoloop_dp_test.go b/staticaddr/loopin/autoloop_dp_test.go new file mode 100644 index 000000000..8dbfd9891 --- /dev/null +++ b/staticaddr/loopin/autoloop_dp_test.go @@ -0,0 +1,425 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/stretchr/testify/require" +) + +// TestSelectNoChangeDepositsWithMemoryBudget covers the dp-specific behavior +// that the default helper does not expose directly: forced scaling and hard +// budget failures. +func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) { + t.Parallel() + + depositSeven := makeDeposit(31, 0, 7_000, 240) + depositFour := makeDeposit(32, 0, 4_000, 241) + depositThree := makeDeposit(33, 0, 3_000, 242) + + testCases := []struct { + name string + maxAmount btcutil.Amount + minAmount btcutil.Amount + deposits []*deposit.Deposit + maxMemory int + expected []*deposit.Deposit + expectedError error + }{ + { + name: "a tight but sufficient budget forces scaled " + + "buckets and still finds the only valid subset", + maxAmount: 10_000, + minAmount: 9_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThree, + }, + maxMemory: 240, + expected: []*deposit.Deposit{ + depositSeven, depositThree, + }, + }, + { + name: "a budget smaller than two state buckets fails " + + "explicitly instead of pretending no " + + "candidate exists", + maxAmount: 10_000, + minAmount: 9_000, + deposits: []*deposit.Deposit{ + depositSeven, depositThree, + }, + maxMemory: 23, + expectedError: errAutoloopDPMemoryBudgetTooSmall, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + deposits, err := selectNoChangeDepositsWithMemoryBudget( + testCase.maxAmount, testCase.minAmount, + testCase.deposits, 1_000, 100, nil, + testCase.maxMemory, + ) + + if testCase.expectedError != nil { + require.ErrorIs(t, err, testCase.expectedError) + require.Nil(t, deposits) + + return + } + + require.NoError(t, err) + require.Equal( + t, depositOutpoints(testCase.expected), + depositOutpoints(deposits), + ) + }) + } +} + +// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier +// to understand directly than by inferring the step from a larger selector +// behavior test. +func TestAutoloopDPSizing(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + maxAmount btcutil.Amount + depositCount int + maxMemoryBytes int + expectedStep btcutil.Amount + expectedBuckets int + expectedWords int + expectedError error + }{ + { + name: "when the table fits exactly the step remains " + + "one", + maxAmount: 100, + depositCount: 1, + maxMemoryBytes: 24 * 200, + expectedStep: 1, + expectedBuckets: 101, + expectedWords: 1, + }, + { + name: "when memory is tight the step increases just " + + "enough to stay inside budget", + maxAmount: 100, + depositCount: 64, + maxMemoryBytes: 24 * 11, + expectedStep: 10, + expectedBuckets: 11, + expectedWords: 1, + }, + { + name: "when the budget cannot hold bucket zero and " + + "one positive bucket, sizing fails", + maxAmount: 100, + depositCount: 64, + maxMemoryBytes: 23, + expectedError: errAutoloopDPMemoryBudgetTooSmall, + expectedWords: 1, + }, + { + name: "a non-positive max amount still rounds the " + + "step up to one after ceiling division " + + "returns zero", + maxAmount: 0, + depositCount: 64, + maxMemoryBytes: 24 * 10, + expectedStep: 1, + expectedBuckets: 1, + expectedWords: 1, + }, + { + name: "when the deposit count exceeds one word the " + + "state size rounds up to the next word", + maxAmount: 100, + depositCount: 65, + maxMemoryBytes: 32 * 50, + expectedStep: 3, + expectedBuckets: 35, + expectedWords: 2, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + require.Equal( + t, testCase.expectedWords, + autoloopDPWordsPerState(testCase.depositCount), + ) + + step, bucketCount, err := autoloopDPSizing( + testCase.maxAmount, testCase.depositCount, + testCase.maxMemoryBytes, + ) + + if testCase.expectedError != nil { + require.ErrorIs(t, err, testCase.expectedError) + require.Zero(t, step) + require.Zero(t, bucketCount) + + return + } + + require.NoError(t, err) + require.Equal(t, testCase.expectedStep, step) + require.Equal(t, testCase.expectedBuckets, bucketCount) + }) + } +} + +// TestAutoloopDPBucketWeight verifies the compressed bucket mapping directly. +// The selector relies on this helper to avoid the round-up bug where several +// individually rounded deposits can make a valid exact sum unreachable. +func TestAutoloopDPBucketWeight(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + value btcutil.Amount + step btcutil.Amount + expected int + }{ + { + name: "values larger than the step are truncated " + + "into the matching floor bucket", + value: 10, + step: 3, + expected: 3, + }, + { + name: "values smaller than the step still consume " + + "one bucket so they remain selectable", + value: 2, + step: 5, + expected: 1, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + require.Equal( + t, testCase.expected, + autoloopDPBucketWeight( + testCase.value, testCase.step, + ), + ) + }) + } +} + +// TestCompareResidualLifeSequences isolates the expiry-order helper. This is +// the selector's "what expires sooner?" rule, so the cases state explicitly +// why the helper should consider one sequence earlier, later, or tied. +func TestCompareResidualLifeSequences(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + left []int64 + right []int64 + expected int + }{ + { + name: "the left sequence is earlier when the first " + + "differing deposit expires sooner", + left: []int64{100, 150}, + right: []int64{100, 200}, + expected: -1, + }, + { + name: "the right sequence is earlier when its first " + + "differing deposit expires sooner", + left: []int64{150, 250}, + right: []int64{150, 200}, + expected: 1, + }, + { + name: "a strict prefix is treated as an expiry tie " + + "so later amount and count rules can decide", + left: []int64{100}, + right: []int64{100, 200}, + expected: 0, + }, + { + name: "the same strict-prefix rule applies " + + "regardless of which side is longer", + left: []int64{100, 200}, + right: []int64{100}, + expected: 0, + }, + { + name: "identical residual-life sequences compare as " + + "equal", + left: []int64{100, 200}, + right: []int64{100, 200}, + expected: 0, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + result := compareResidualLifeSequences( + testCase.left, testCase.right, + ) + require.Equal(t, testCase.expected, result) + }) + } +} + +// TestFilterAutoloopCandidateDeposits covers the low-level filter and sort +// helper so selector tests do not have to infer ordering rules indirectly. +func TestFilterAutoloopCandidateDeposits(t *testing.T) { + t.Parallel() + + earlierSameValue := makeDeposit(41, 0, 5_000, 200) + laterSameValueA := makeDeposit(42, 0, 5_000, 210) + laterSameValueB := makeDeposit(43, 0, 5_000, 210) + oversized := makeDeposit(44, 0, 9_000, 220) + unswappable := makeDeposit(45, 0, 4_000, 149) + + selectedDeposits, total := filterAutoloopCandidateDeposits( + 7_000, + []*deposit.Deposit{ + laterSameValueB, oversized, earlierSameValue, + unswappable, laterSameValueA, + }, + 1_000, 100, nil, + ) + + require.Equal(t, btcutil.Amount(15_000), total) + require.Equal( + t, + []string{ + earlierSameValue.OutPoint.String(), + laterSameValueA.OutPoint.String(), + laterSameValueB.OutPoint.String(), + }, + candidateOutpoints(selectedDeposits), + ) +} + +// TestAutoloopDPComparators isolates helper branches that are awkward to hit +// predictably through the full selector alone. +func TestAutoloopDPComparators(t *testing.T) { + t.Parallel() + + makeCandidateDeposits := func( + deposits ...*deposit.Deposit) []autoloopCandidateDeposit { + + candidates := make( + []autoloopCandidateDeposit, 0, len(deposits), + ) + for _, deposit := range deposits { + candidate := autoloopCandidateDeposit{ + deposit: deposit, + residualLife: deposit.ConfirmationHeight, + outpoint: deposit.OutPoint.String(), + } + candidates = append(candidates, candidate) + } + + return candidates + } + + t.Run("candidateBeatsState prefers a larger exact sum in the same "+ + "bucket", + func(t *testing.T) { + + t.Parallel() + + small := makeDeposit(51, 0, 4_000, 300) + medium := makeDeposit(52, 0, 5_000, 301) + later := makeDeposit(53, 0, 1_000, 302) + + candidates := makeCandidateDeposits( + small, medium, later, + ) + table := newAutoloopDPTable(3, len(candidates)) + table.counts[0] = 0 + table.copyStateFromSource(1, 0, 0, small.Value) + table.copyStateFromSource(2, 0, 1, medium.Value) + + require.True(t, table.candidateBeatsState( + 2, 1, 2, medium.Value+later.Value, candidates, + )) + }, + ) + + t.Run("stateBeatsStateWithinBand falls back to sum when expiry ties", + func(t *testing.T) { + t.Parallel() + + six := makeDeposit(54, 0, 6_000, 300) + five := makeDeposit(55, 0, 5_000, 300) + + candidates := makeCandidateDeposits(six, five) + table := newAutoloopDPTable(3, len(candidates)) + table.counts[0] = 0 + table.copyStateFromSource(1, 0, 0, six.Value) + table.copyStateFromSource(2, 0, 1, five.Value) + + require.True(t, table.stateBeatsStateWithinBand( + 1, 2, candidates, + )) + }, + ) + + t.Run("stateBeatsStateWithinBand falls back to fewer deposits "+ + "when expiry and sum tie", + func(t *testing.T) { + t.Parallel() + + six := makeDeposit(56, 0, 6_000, 300) + five := makeDeposit(57, 0, 5_000, 300) + one := makeDeposit(58, 0, 1_000, 300) + + candidates := makeCandidateDeposits(six, five, one) + table := newAutoloopDPTable(4, len(candidates)) + table.counts[0] = 0 + table.copyStateFromSource(1, 0, 0, six.Value) + table.copyStateFromSource(2, 0, 1, five.Value) + table.copyStateFromSource(3, 2, 2, five.Value+one.Value) + + require.True(t, table.stateBeatsStateWithinBand( + 1, 3, candidates, + )) + }, + ) +} + +// depositOutpoints turns a deposit set into a stable, readable assertion +// surface for the selector tests. +func depositOutpoints(deposits []*deposit.Deposit) []string { + outpoints := make([]string, 0, len(deposits)) + for _, selectedDeposit := range deposits { + outpoints = append(outpoints, selectedDeposit.OutPoint.String()) + } + + return outpoints +} + +// candidateOutpoints exposes the filtered candidate order in a readable form. +func candidateOutpoints( + deposits []autoloopCandidateDeposit) []string { + + outpoints := make([]string, 0, len(deposits)) + for _, candidateDeposit := range deposits { + outpoints = append(outpoints, candidateDeposit.outpoint) + } + + return outpoints +} diff --git a/staticaddr/loopin/autoloop_test.go b/staticaddr/loopin/autoloop_test.go index 2e32eea5f..75539d993 100644 --- a/staticaddr/loopin/autoloop_test.go +++ b/staticaddr/loopin/autoloop_test.go @@ -11,10 +11,15 @@ import ( "github.com/stretchr/testify/require" ) -// TestSelectNoChangeDeposits verifies the full-deposit static-autoloop -// selector. The cases below target the filter paths, the branch-and-bound -// search, and every documented tie-breaker explicitly so coverage tracks the -// actual selection behavior instead of a handful of happy-path examples. +// TestSelectNoChangeDeposits exercises the bounded-memory selector end to end. +// The full decision rule: +// +// 1. build only full-deposit, no-change candidates +// 2. find the best reachable total in the requested range +// 3. allow a band that gives back up to 25 percent of the gain above +// minAmount +// 4. inside that band, prefer earlier-expiring deposits +// 5. fall back to larger total, then fewer deposits func TestSelectNoChangeDeposits(t *testing.T) { depositSeven := makeDeposit(7, 0, 7_000, 200) depositFour := makeDeposit(4, 0, 4_000, 210) @@ -34,6 +39,7 @@ func TestSelectNoChangeDeposits(t *testing.T) { depositUnsuitable := makeDeposit(18, 0, 6_000, 149) depositOversized := makeDeposit(19, 0, 9_000, 220) depositTwo := makeDeposit(20, 0, 2_000, 210) + depositTen := makeDeposit(23, 0, 10_000, 500) testCases := []struct { name string @@ -189,6 +195,47 @@ func TestSelectNoChangeDeposits(t *testing.T) { depositNine, depositFourA, }, }, + { + // A slightly smaller total can win when it stays inside + // the band that gives back only part of the gain above + // minAmount. + name: "smaller earlier-expiring combo can win " + + "inside band", + maxAmount: 10_000, + minAmount: 6_000, + deposits: []*deposit.Deposit{ + depositTen, depositFive, depositFourC, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFive, depositFourC, + }, + }, + { + name: "smaller earlier candidate below band does not " + + "beat best total", + maxAmount: 9_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositNine, depositSeven, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositNine}, + }, + { + name: "returns no candidate when enough value exists " + + "but no subset fits range", + maxAmount: 6_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositFourC, depositFourD, depositFourE, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, } selectedOutpoints := func(deposits []*deposit.Deposit) []string {