From 2e57c5edf139dbd3b2997a4356a5d35a162c4634 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:41:52 -0500 Subject: [PATCH 1/9] 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 | 22 ++ 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, 439 insertions(+), 231 deletions(-) create mode 100644 liquidity/loopin_source.go diff --git a/cmd/loop/liquidity.go b/cmd/loop/liquidity.go index 5492f2045..686828e02 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,23 @@ 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") + } + + 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 eed27b2ad0f7427adac7259000ad17edf4b28c8b Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 00:09:14 -0500 Subject: [PATCH 2/9] 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..5d0dbb468 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -2105,6 +2105,13 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context, req.LastHop = &lastHop } + // 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) + } + loopIn, err := s.staticLoopInManager.DeliverLoopInRequest(ctx, req) if err != nil { return nil, err 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 50c84dc1c934945ce95ea817fc7220a40a21d0e6 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 14:55:53 -0500 Subject: [PATCH 3/9] 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 ac3963283ffb5d6e851cf7ff9c1fab65b68d22fb Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 00:11:52 -0500 Subject: [PATCH 4/9] 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 6428338d9d843fde360da1caa5cb83684861a7bd Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:51:19 -0500 Subject: [PATCH 5/9] 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 | 103 ++++++++ liquidity/static_loopin_test.go | 274 +++++++++++++++++++++ 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, 801 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..5bb46bfa3 --- /dev/null +++ b/liquidity/static_loopin.go @@ -0,0 +1,103 @@ +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 { + + htlcFee := htlcFeeRate.FeeForWeight( + staticLoopInHtlcWeight(numDeposits, hasChange), + ) + + sweepFee := loopInSweepFee(timeoutSweepFeeRate) + + return htlcFee + sweepFee +} + +// 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..a8abf5bc9 --- /dev/null +++ b/liquidity/static_loopin_test.go @@ -0,0 +1,274 @@ +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: "sweep fee only", + numDeposits: 2, + htlcFeeRate: 0, + timeoutSweepFeeRate: chainfee.SatPerKWeight(1_100), + expected: 485, + }, + } + + 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, + HtlcTxFeeRate: chainfee.SatPerKWeight(900), + 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, chainfee.SatPerKWeight(900), + 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 0e98cb34edec970939e4bd512d434aff1c6be3b6 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 10 Apr 2026 23:58:29 -0500 Subject: [PATCH 6/9] 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 5d0dbb468..92dc01768 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 b22fe97a25d7dc01559f606e9008e6141b4c3cdb Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Sat, 11 Apr 2026 00:00:25 -0500 Subject: [PATCH 7/9] 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 5bb46bfa3..2bb62f83f 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, @@ -101,3 +229,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 a8abf5bc9..d843d5a1c 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" ) @@ -272,3 +276,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 86039857d7023e4d554863f5974f1c3623a2590a Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 13 Apr 2026 01:17:30 -0500 Subject: [PATCH 8/9] 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 92dc01768..39cf4a71c 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 4f42af0388d88f256bd976bd37520ea68395a975 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Tue, 14 Apr 2026 03:46:57 -0500 Subject: [PATCH 9/9] 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 | `[]` |