Merge pull request #1119 from starius/static-autoloop

autoloop: support static loop-ins
This commit is contained in:
Boris Nagaev 2026-05-22 02:50:42 -05:00 committed by GitHub
commit 859ba4508e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4285 additions and 374 deletions

View file

@ -340,6 +340,12 @@ 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. Static-address " +
"requires loopd --experimental.",
},
&cli.BoolFlag{
Name: "easyautoloop",
Usage: "set to true to enable easy autoloop, which " +
@ -555,6 +561,25 @@ func setParams(ctx context.Context, cmd *cli.Command) error {
flagSet = true
}
if cmd.IsSet("loopinsource") {
switch cmd.String("loopinsource") {
case "wallet":
params.LoopInSource =
looprpc.LoopInSource_LOOP_IN_SOURCE_WALLET
case "static-address", "static_address", "static":
params.LoopInSource =
looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS
default:
return fmt.Errorf("unknown loopinsource value %q "+
"(use \"wallet\" or \"static-address\")",
cmd.String("loopinsource"))
}
flagSet = true
}
// If we are setting easy autoloop parameters, we need to ensure that
// the asset ID is set, and that we have a valid entry in our params
// map.

View file

@ -85,6 +85,7 @@ func writeSessionFilePath(path string, fixture sessionFile) error {
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
return encoder.Encode(fixture)
@ -152,7 +153,7 @@ func rewriteSessionFixture(fixture sessionFile,
func rewriteExitEventRunError(events []sessionEvent, runError *string,
changed bool) ([]sessionEvent, bool, error) {
data, err := json.Marshal(exitPayload{
data, err := marshalSessionJSON(exitPayload{
RunError: cloneOptionalString(runError),
})
if err != nil {
@ -338,7 +339,7 @@ func buildReplacementTextEvents(events []sessionEvent, indices []int,
replacements := make([]sessionEvent, 0, len(chunks))
for i, chunk := range chunks {
data, err := json.Marshal(newTextPayload(chunk))
data, err := marshalSessionJSON(newTextPayload(chunk))
if err != nil {
return nil, err
}
@ -571,7 +572,7 @@ func TestRewriteSessionFixtureSkipsNormalizedTimestampNoise(t *testing.T) {
func textEvent(t *testing.T, timeMS int64, kind, text string) sessionEvent {
t.Helper()
data, err := json.Marshal(newTextPayload(text))
data, err := marshalSessionJSON(newTextPayload(text))
require.NoError(t, err)
return sessionEvent{
@ -585,7 +586,7 @@ func textEvent(t *testing.T, timeMS int64, kind, text string) sessionEvent {
func exitEvent(t *testing.T, timeMS int64, runError *string) sessionEvent {
t.Helper()
data, err := json.Marshal(exitPayload{
data, err := marshalSessionJSON(exitPayload{
RunError: cloneOptionalString(runError),
})
require.NoError(t, err)

View file

@ -1,6 +1,7 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -48,6 +49,20 @@ var grpcMarshalOptions = protojson.MarshalOptions{
EmitUnpopulated: true,
}
// marshalSessionJSON encodes nested session payloads without HTML escaping so
// recorded fixture strings stay byte-for-byte close to the CLI text.
func marshalSessionJSON(value any) ([]byte, error) {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
}
// sessionRecorder captures CLI IO and gRPC traffic for replay.
type sessionRecorder struct {
mu sync.Mutex
@ -80,9 +95,9 @@ type sessionMetadata struct {
Args []string `json:"args"`
Env map[string]string `json:"env"`
Version string `json:"version"`
ClockStartUnix int64 `json:"clock_start_unix"`
RunError *string `json:"run_error,omitempty"`
Duration *time.Duration `json:"duration,omitempty"`
ClockStartUnix int64 `json:"clock_start_unix"`
}
// sessionEvent records a single timestamped payload entry.
@ -262,7 +277,7 @@ func ensureSessionBaseDir(baseDir string) error {
// logEvent records a new event with the elapsed timestamp.
func (r *sessionRecorder) logEvent(kind string, payload any) {
data, err := json.Marshal(payload)
data, err := marshalSessionJSON(payload)
if err != nil {
r.mu.Lock()
if r.eventErr == nil {
@ -434,6 +449,7 @@ func (r *sessionRecorder) finalize(runErr error) error {
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(fileContent); err != nil {
finalizeErr = errors.Join(err, eventErr, hookErr)
@ -563,7 +579,7 @@ func (r *sessionRecorder) logGRPCMessage(method, event string, msg any,
payload.Payload = data
}
} else {
data, err := json.Marshal(msg)
data, err := marshalSessionJSON(msg)
if err == nil {
payload.Payload = data
}

View file

@ -70,6 +70,7 @@ Base URL: `http://127.0.0.1:12345`
| `misc/` | `loop terms` |
| `quote/` | `loop quote out` (success + verbose), `loop quote in` (help + verbose), `loop quote out` (help), `loop quote in` (deposit_outpoint success), `loop quote in` (positional + last_hop) |
| `static/` | `loop static withdraw` (no selection error), `loop static withdraw` (invalid utxo), `loop static withdraw` (all success), `loop static withdraw` (utxo + dest_addr success), `loop static listwithdrawals`, `loop static listswaps` |
| `static-autoloop/` | `loop setparams --loopinsource static-address` (success + no-experimental error), `loop getparams` (static-address loop-in source), `loop suggestswaps` (static loop-in suggestion) |
| `static-loop-in/` | `loop static new`, `loop static` (help), `loop static listunspent` (incl alias), `loop static listdeposits`, `loop static summary`, `loop static in` (multiple args/flags cases), `loop static in` (duplicate outpoints), `loop static in` (positional low amount error), `loop static in` (positional + last_hop + payment_timeout), `loop static in` (all cancel) |
| `static-filters/` | `loop static listdeposits --filter ...` for each state (deposited/withdrawing/withdrawn/looping_in/looped_in/publish_expired_deposit/sweep_htlc_timeout/htlc_timeout_swept/wait_for_expiry_sweep/expired/failed) |
| `swaps/` | `loop listswaps` (success + conflicting filters + loop_out_only filters + loop_in_only), `loop swapinfo` (success + invalid id + id flag errors), `loop abandonswap` (help + invalid id + success) |

View file

@ -58,7 +58,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -85,6 +86,7 @@
" \"fast_swap_publication\": true,\n",
" \"fee_ppm\": \"20000\",\n",
" \"htlc_conf_target\": 6,\n",
" \"loop_in_source\": \"LOOP_IN_SOURCE_WALLET\",\n",
" \"max_miner_fee_sat\": \"0\",\n",
" \"max_prepay_routing_fee_ppm\": \"0\",\n",
" \"max_prepay_sat\": \"0\",\n",

View file

@ -68,7 +68,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},

View file

@ -60,7 +60,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -99,7 +100,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -72,7 +72,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},

View file

@ -69,7 +69,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},

View file

@ -70,7 +70,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -118,7 +119,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -46,7 +46,8 @@
"{\n",
" \"disqualified\": [],\n",
" \"loop_in\": [],\n",
" \"loop_out\": []\n",
" \"loop_out\": [],\n",
" \"static_loop_in\": []\n",
"}\n"
]
}

View file

@ -70,7 +70,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -126,7 +127,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -81,7 +81,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -145,7 +146,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -77,7 +77,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -125,7 +126,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -101,7 +101,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -151,7 +152,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -103,7 +103,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -153,7 +154,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -102,7 +102,8 @@
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -152,7 +153,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -71,7 +71,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -121,7 +122,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -73,7 +73,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -123,7 +124,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -70,7 +70,8 @@
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [
"AnHW4pMBFZ2eHMXTmDR5pR87PAxoLtp/FqofR9/gmyL3"
]
],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -118,7 +119,8 @@
"account_addr_type": "TAPROOT_PUBKEY",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -72,7 +72,8 @@
"account_addr_type": "TAPROOT_PUBKEY",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
@ -128,7 +129,8 @@
"account_addr_type": "TAPROOT_PUBKEY",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": []
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
}

View file

@ -0,0 +1,151 @@
{
"metadata": {
"args": [
"/home/user/bin/loop",
"setparams",
"--network",
"regtest",
"--loopinsource",
"static-address",
"--minamt",
"250000",
"--maxamt",
"1000000",
"--feepercent",
"5",
"--autoloop",
"--autobudget",
"50000",
"--autoinflight",
"1"
],
"version": "0.33.0-beta commit=v0.33.0-beta-74-g6a3c3e1df5f807b450d3ba663f4824f6588e1c13 commit_hash=6a3c3e1df5f807b450d3ba663f4824f6588e1c13",
"duration": 32231921,
"clock_start_unix": 1779406641
},
"events": [
{
"time_ms": 10,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "request",
"message_type": "looprpc.GetLiquidityParamsRequest",
"payload": {}
}
},
{
"time_ms": 16,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "response",
"message_type": "looprpc.LiquidityParameters",
"payload": {
"rules": [
{
"channel_id": "0",
"swap_type": "LOOP_IN",
"pubkey": "AgN4nvpxLgk471Ol2/D2BGUJ9l/FOlv8dRHcZ/6+oaFl",
"type": "THRESHOLD",
"incoming_threshold": 0,
"outgoing_threshold": 60
}
],
"fee_ppm": "50000",
"sweep_fee_rate_sat_per_vbyte": "0",
"max_swap_fee_ppm": "0",
"max_routing_fee_ppm": "0",
"max_prepay_routing_fee_ppm": "0",
"max_prepay_sat": "0",
"max_miner_fee_sat": "0",
"sweep_conf_target": 100,
"failure_backoff_sec": "86400",
"autoloop": true,
"autoloop_budget_sat": "50000",
"autoloop_budget_start_sec": "0",
"auto_max_in_flight": "1",
"min_swap_amount": "250000",
"max_swap_amount": "1000000",
"htlc_conf_target": 6,
"autoloop_dest_address": "",
"autoloop_budget_refresh_period_sec": "604800",
"autoloop_budget_last_refresh": "1779405910",
"easy_autoloop": false,
"easy_autoloop_local_target_sat": "0",
"account": "",
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_STATIC_ADDRESS"
}
}
},
{
"time_ms": 18,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SetLiquidityParams",
"event": "request",
"message_type": "looprpc.SetLiquidityParamsRequest",
"payload": {
"parameters": {
"rules": [
{
"channel_id": "0",
"swap_type": "LOOP_IN",
"pubkey": "AgN4nvpxLgk471Ol2/D2BGUJ9l/FOlv8dRHcZ/6+oaFl",
"type": "THRESHOLD",
"incoming_threshold": 0,
"outgoing_threshold": 60
}
],
"fee_ppm": "50000",
"sweep_fee_rate_sat_per_vbyte": "0",
"max_swap_fee_ppm": "0",
"max_routing_fee_ppm": "0",
"max_prepay_routing_fee_ppm": "0",
"max_prepay_sat": "0",
"max_miner_fee_sat": "0",
"sweep_conf_target": 100,
"failure_backoff_sec": "86400",
"autoloop": true,
"autoloop_budget_sat": "50000",
"autoloop_budget_start_sec": "0",
"auto_max_in_flight": "1",
"min_swap_amount": "250000",
"max_swap_amount": "1000000",
"htlc_conf_target": 6,
"autoloop_dest_address": "",
"autoloop_budget_refresh_period_sec": "604800",
"autoloop_budget_last_refresh": "1779405910",
"easy_autoloop": false,
"easy_autoloop_local_target_sat": "0",
"account": "",
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_STATIC_ADDRESS"
}
}
}
},
{
"time_ms": 31,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SetLiquidityParams",
"event": "response",
"message_type": "looprpc.SetLiquidityParamsResponse",
"payload": {}
}
},
{
"time_ms": 32,
"kind": "exit",
"data": {}
}
]
}

View file

@ -0,0 +1,125 @@
{
"metadata": {
"args": [
"/home/user/bin/loop",
"getparams",
"--network",
"regtest"
],
"version": "0.33.0-beta commit=v0.33.0-beta-74-g6a3c3e1df5f807b450d3ba663f4824f6588e1c13 commit_hash=6a3c3e1df5f807b450d3ba663f4824f6588e1c13",
"duration": 12324887,
"clock_start_unix": 1779406647
},
"events": [
{
"time_ms": 1,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "request",
"message_type": "looprpc.GetLiquidityParamsRequest",
"payload": {}
}
},
{
"time_ms": 5,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "response",
"message_type": "looprpc.LiquidityParameters",
"payload": {
"rules": [
{
"channel_id": "0",
"swap_type": "LOOP_IN",
"pubkey": "AgN4nvpxLgk471Ol2/D2BGUJ9l/FOlv8dRHcZ/6+oaFl",
"type": "THRESHOLD",
"incoming_threshold": 0,
"outgoing_threshold": 60
}
],
"fee_ppm": "50000",
"sweep_fee_rate_sat_per_vbyte": "0",
"max_swap_fee_ppm": "0",
"max_routing_fee_ppm": "0",
"max_prepay_routing_fee_ppm": "0",
"max_prepay_sat": "0",
"max_miner_fee_sat": "0",
"sweep_conf_target": 100,
"failure_backoff_sec": "86400",
"autoloop": true,
"autoloop_budget_sat": "50000",
"autoloop_budget_start_sec": "0",
"auto_max_in_flight": "1",
"min_swap_amount": "250000",
"max_swap_amount": "1000000",
"htlc_conf_target": 6,
"autoloop_dest_address": "",
"autoloop_budget_refresh_period_sec": "604800",
"autoloop_budget_last_refresh": "1779405910",
"easy_autoloop": false,
"easy_autoloop_local_target_sat": "0",
"account": "",
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_STATIC_ADDRESS"
}
}
},
{
"time_ms": 12,
"kind": "stdout",
"data": {
"lines": [
"{\n",
" \"account\": \"\",\n",
" \"account_addr_type\": \"ADDRESS_TYPE_UNKNOWN\",\n",
" \"auto_max_in_flight\": \"1\",\n",
" \"autoloop\": true,\n",
" \"autoloop_budget_last_refresh\": \"1779405910\",\n",
" \"autoloop_budget_refresh_period_sec\": \"604800\",\n",
" \"autoloop_budget_sat\": \"50000\",\n",
" \"autoloop_budget_start_sec\": \"0\",\n",
" \"autoloop_dest_address\": \"\",\n",
" \"easy_asset_params\": {},\n",
" \"easy_autoloop\": false,\n",
" \"easy_autoloop_excluded_peers\": [],\n",
" \"easy_autoloop_local_target_sat\": \"0\",\n",
" \"failure_backoff_sec\": \"86400\",\n",
" \"fast_swap_publication\": true,\n",
" \"fee_ppm\": \"50000\",\n",
" \"htlc_conf_target\": 6,\n",
" \"loop_in_source\": \"LOOP_IN_SOURCE_STATIC_ADDRESS\",\n",
" \"max_miner_fee_sat\": \"0\",\n",
" \"max_prepay_routing_fee_ppm\": \"0\",\n",
" \"max_prepay_sat\": \"0\",\n",
" \"max_routing_fee_ppm\": \"0\",\n",
" \"max_swap_amount\": \"1000000\",\n",
" \"max_swap_fee_ppm\": \"0\",\n",
" \"min_swap_amount\": \"250000\",\n",
" \"rules\": [\n",
" {\n",
" \"channel_id\": \"0\",\n",
" \"incoming_threshold\": 0,\n",
" \"outgoing_threshold\": 60,\n",
" \"pubkey\": \"0203789efa712e0938ef53a5dbf0f6046509f65fc53a5bfc7511dc67febea1a165\",\n",
" \"swap_type\": \"LOOP_IN\",\n",
" \"type\": \"THRESHOLD\"\n",
" }\n",
" ],\n",
" \"sweep_conf_target\": 100,\n",
" \"sweep_fee_rate_sat_per_vbyte\": \"0\"\n",
"}\n"
]
}
},
{
"time_ms": 12,
"kind": "exit",
"data": {}
}
]
}

View file

@ -0,0 +1,101 @@
{
"metadata": {
"args": [
"/home/user/bin/loop",
"suggestswaps",
"--network",
"regtest"
],
"version": "0.33.0-beta commit=v0.33.0-beta-74-g6a3c3e1df5f807b450d3ba663f4824f6588e1c13 commit_hash=6a3c3e1df5f807b450d3ba663f4824f6588e1c13",
"duration": 44264819,
"clock_start_unix": 1779406653
},
"events": [
{
"time_ms": 1,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SuggestSwaps",
"event": "request",
"message_type": "looprpc.SuggestSwapsRequest",
"payload": {}
}
},
{
"time_ms": 43,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SuggestSwaps",
"event": "response",
"message_type": "looprpc.SuggestSwapsResponse",
"payload": {
"loop_out": [],
"loop_in": [],
"static_loop_in": [
{
"outpoints": [
"9cfb0ec5ab2c851b4db5fe372cc3573f4074c436e50b13d2b7d214e45571def9:1"
],
"max_swap_fee_satoshis": "1828",
"last_hop": "AgN4nvpxLgk471Ol2/D2BGUJ9l/FOlv8dRHcZ/6+oaFl",
"label": "[reserved]: autoloop-in",
"initiator": "autoloop",
"route_hints": [],
"private": false,
"payment_timeout_seconds": 0,
"amount": "500000",
"fast": false
}
],
"disqualified": [
{
"channel_id": "127543348887552",
"pubkey": "",
"reason": "AUTO_REASON_CUSTOM_CHANNEL_DATA"
}
]
}
}
},
{
"time_ms": 43,
"kind": "stdout",
"data": {
"lines": [
"{\n",
" \"disqualified\": [\n",
" {\n",
" \"channel_id\": \"127543348887552\",\n",
" \"pubkey\": \"\",\n",
" \"reason\": \"AUTO_REASON_CUSTOM_CHANNEL_DATA\"\n",
" }\n",
" ],\n",
" \"loop_in\": [],\n",
" \"loop_out\": [],\n",
" \"static_loop_in\": [\n",
" {\n",
" \"amount\": \"500000\",\n",
" \"fast\": false,\n",
" \"initiator\": \"autoloop\",\n",
" \"label\": \"[reserved]: autoloop-in\",\n",
" \"last_hop\": \"0203789efa712e0938ef53a5dbf0f6046509f65fc53a5bfc7511dc67febea1a165\",\n",
" \"max_swap_fee_satoshis\": \"1828\",\n",
" \"outpoints\": [\n",
" \"9cfb0ec5ab2c851b4db5fe372cc3573f4074c436e50b13d2b7d214e45571def9:1\"\n",
" ],\n",
" \"payment_timeout_seconds\": 0,\n",
" \"private\": false,\n",
" \"route_hints\": []\n",
" }\n",
" ]\n",
"}\n"
]
}
},
{
"time_ms": 44,
"kind": "exit",
"data": {}
}
]
}

View file

@ -0,0 +1,137 @@
{
"metadata": {
"args": [
"/home/user/bin/loop",
"setparams",
"--network",
"regtest",
"--loopinsource",
"static-address"
],
"version": "0.33.0-beta commit=v0.33.0-beta-74-g6a3c3e1df5f807b450d3ba663f4824f6588e1c13 commit_hash=6a3c3e1df5f807b450d3ba663f4824f6588e1c13",
"run_error": "rpc error: code = FailedPrecondition desc = static address autoloop requires --experimental",
"duration": 12501068,
"clock_start_unix": 1779406912
},
"events": [
{
"time_ms": 4,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "request",
"message_type": "looprpc.GetLiquidityParamsRequest",
"payload": {}
}
},
{
"time_ms": 11,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/GetLiquidityParams",
"event": "response",
"message_type": "looprpc.LiquidityParameters",
"payload": {
"rules": [],
"fee_ppm": "20000",
"sweep_fee_rate_sat_per_vbyte": "0",
"max_swap_fee_ppm": "0",
"max_routing_fee_ppm": "0",
"max_prepay_routing_fee_ppm": "0",
"max_prepay_sat": "0",
"max_miner_fee_sat": "0",
"sweep_conf_target": 100,
"failure_backoff_sec": "86400",
"autoloop": false,
"autoloop_budget_sat": "335544",
"autoloop_budget_start_sec": "0",
"auto_max_in_flight": "1",
"min_swap_amount": "0",
"max_swap_amount": "0",
"htlc_conf_target": 6,
"autoloop_dest_address": "",
"autoloop_budget_refresh_period_sec": "604800",
"autoloop_budget_last_refresh": "1779406903",
"easy_autoloop": false,
"easy_autoloop_local_target_sat": "0",
"account": "",
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_WALLET"
}
}
},
{
"time_ms": 11,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SetLiquidityParams",
"event": "request",
"message_type": "looprpc.SetLiquidityParamsRequest",
"payload": {
"parameters": {
"rules": [],
"fee_ppm": "20000",
"sweep_fee_rate_sat_per_vbyte": "0",
"max_swap_fee_ppm": "0",
"max_routing_fee_ppm": "0",
"max_prepay_routing_fee_ppm": "0",
"max_prepay_sat": "0",
"max_miner_fee_sat": "0",
"sweep_conf_target": 100,
"failure_backoff_sec": "86400",
"autoloop": false,
"autoloop_budget_sat": "335544",
"autoloop_budget_start_sec": "0",
"auto_max_in_flight": "1",
"min_swap_amount": "0",
"max_swap_amount": "0",
"htlc_conf_target": 6,
"autoloop_dest_address": "",
"autoloop_budget_refresh_period_sec": "604800",
"autoloop_budget_last_refresh": "1779406903",
"easy_autoloop": false,
"easy_autoloop_local_target_sat": "0",
"account": "",
"account_addr_type": "ADDRESS_TYPE_UNKNOWN",
"easy_asset_params": {},
"fast_swap_publication": true,
"easy_autoloop_excluded_peers": [],
"loop_in_source": "LOOP_IN_SOURCE_STATIC_ADDRESS"
}
}
}
},
{
"time_ms": 12,
"kind": "grpc",
"data": {
"method": "/looprpc.SwapClient/SetLiquidityParams",
"event": "error",
"error": "rpc error: code = FailedPrecondition desc = static address autoloop requires --experimental",
"status": {
"code": 9,
"message": "static address autoloop requires --experimental"
}
}
},
{
"time_ms": 12,
"kind": "stderr",
"data": {
"lines": [
"[loop] rpc error: code = FailedPrecondition desc = static address autoloop requires --experimental\n"
]
}
},
{
"time_ms": 12,
"kind": "exit",
"data": {
"run_error": "rpc error: code = FailedPrecondition desc = static address autoloop requires --experimental"
}
}
]
}

View file

@ -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. Static-address requires loopd --experimental.
.PP
\fB--maxamt\fP="": the maximum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0)

View file

@ -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. Static-address requires loopd --experimental | string |
| `--easyautoloop` | set to true to enable easy autoloop, which will automatically dispatch swaps in order to meet the target local balance | bool | `false` |
| `--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 | `[]` |

View file

@ -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}, &params.ClientRestrictions,
nil, nil, 1,
picked, err := c.manager.pickEasyAutoloopChannel(
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
&params.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}, &params.ClientRestrictions,
nil, nil, 1,
picked, err := c.manager.pickEasyAutoloopChannel(
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
&params.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}, &params.ClientRestrictions,
nil, nil, 1,
picked, err = c.manager.pickEasyAutoloopChannel(
t.Context(), []lndclient.ChannelInfo{ch1, ch2},
&params.ClientRestrictions, nil, nil, 1,
)
require.NoError(t, err)
require.NotNil(t, picked)
require.Equal(
t, ch1.ChannelID, picked.ChannelID,

View file

@ -168,6 +168,11 @@ var (
// account address type is not or vice versa.
ErrAccountAndAddrType = errors.New("account and address type have " +
"to be both either set or unset")
// ErrStaticAddressAutoloopExperimental is returned when static-address
// autoloop is configured without the daemon experimental flag.
ErrStaticAddressAutoloopExperimental = errors.New("static address " +
"autoloop requires --experimental")
)
// Config contains the external functionality required to run the
@ -213,6 +218,23 @@ 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)
// EnableStaticAddressAutoloop allows the liquidity manager to accept
// static-address loop-in sources for new autoloop parameters.
EnableStaticAddressAutoloop bool
// LoopInTerms returns the terms for a loop in swap.
LoopInTerms func(ctx context.Context,
initiator string) (*loop.LoopInTerms, error)
@ -221,6 +243,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,
@ -374,6 +401,15 @@ func (m *Manager) SetParameters(ctx context.Context,
func (m *Manager) setParameters(ctx context.Context,
params Parameters) error {
// Static-address autoloop is still experimental. Check this before
// any network-dependent validation so persisted params cannot start
// the feature after a restart without the daemon opt-in.
if params.LoopInSource == LoopInSourceStaticAddress &&
!m.cfg.EnableStaticAddressAutoloop {
return ErrStaticAddressAutoloopExperimental
}
restrictions, err := m.cfg.Restrictions(
ctx, swap.TypeOut, getInitiator(m.params),
)
@ -497,6 +533,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
}
@ -574,9 +634,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 +710,14 @@ 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 +796,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 +914,14 @@ 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")
}
@ -900,6 +990,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
@ -924,6 +1018,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)
}
@ -931,6 +1028,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).
@ -948,12 +1064,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) {
@ -990,9 +1105,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,11 +1159,13 @@ 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
resp = newSuggestions()
candidates []suggestionCandidate
resp = newSuggestions()
)
for peer, balances := range peerChannels {
@ -1064,7 +1188,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
return nil, err
}
suggestions = append(suggestions, suggestion)
candidates = append(candidates, suggestion)
}
for _, channel := range channels {
@ -1099,18 +1223,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
@ -1119,8 +1243,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
@ -1129,7 +1253,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
@ -1139,7 +1263,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.
@ -1148,12 +1305,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
}
@ -1175,18 +1332,85 @@ 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)
}
}
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)
}
// 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
@ -1230,6 +1454,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,
)
@ -1308,12 +1549,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 +1627,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 +1723,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 +1777,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 +1980,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 +2074,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 {

View file

@ -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,
@ -262,11 +384,13 @@ func TestPersistParams(t *testing.T) {
FeePpm: 100,
AutoMaxInFlight: 10,
HtlcConfTarget: 2,
LoopInSource: clientrpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
}
cfg, _ := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
manager := NewManager(cfg)
ctxb := context.Background()
ctx := t.Context()
var paramsBytes []byte
@ -276,7 +400,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 +413,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,7 +433,31 @@ 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,
)
}
// TestStaticAddressAutoloopRequiresExperimental verifies that static-address
// loop-in sources are rejected unless the daemon enabled the experimental
// static autoloop path.
func TestStaticAddressAutoloopRequiresExperimental(t *testing.T) {
ctx := t.Context()
cfg, _ := newTestConfig()
manager := NewManager(cfg)
params := manager.GetParameters()
params.LoopInSource = LoopInSourceStaticAddress
err := manager.setParameters(ctx, params)
require.ErrorIs(t, err, ErrStaticAddressAutoloopExperimental)
cfg.EnableStaticAddressAutoloop = true
err = manager.setParameters(ctx, params)
require.NoError(t, err)
}
@ -2032,12 +2181,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)
}
}

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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,12 @@ 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"
default:
return "unknown"
}

299
liquidity/static_loopin.go Normal file
View file

@ -0,0 +1,299 @@
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 {
// 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
}
// 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,
swapFee btcutil.Amount, htlcFeeRate,
timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount {
successFee := swapFee
timeoutFee := staticLoopInOnchainFee(
numDeposits, hasChange, htlcFeeRate, timeoutSweepFeeRate,
)
return max(timeoutFee, successFee)
}
// staticLoopInOnchainFee estimates the fee for the server-published HTLC
// transaction and client sweep transaction.
func staticLoopInOnchainFee(numDeposits int, hasChange bool, htlcFeeRate,
timeoutSweepFeeRate chainfee.SatPerKWeight) btcutil.Amount {
htlcFeeRate = staticLoopInHtlcFeeRate(
htlcFeeRate, timeoutSweepFeeRate,
)
htlcFee := htlcFeeRate.FeeForWeight(
staticLoopInHtlcWeight(numDeposits, hasChange),
)
sweepFee := loopInSweepFee(timeoutSweepFeeRate)
return htlcFee + sweepFee
}
// staticLoopInHtlcFeeRate returns the best HTLC fee rate known to the planner.
// Pending static loop-ins do not persist their concrete HTLC fee rate until the
// server returns the HTLC package, so liquidity has to reuse the same
// conservative fallback it already uses for dry-run filtering when the stored
// rate is still zero.
func staticLoopInHtlcFeeRate(htlcFeeRate,
timeoutSweepFeeRate chainfee.SatPerKWeight) chainfee.SatPerKWeight {
if htlcFeeRate == 0 {
return timeoutSweepFeeRate
}
return htlcFeeRate
}
// staticLoopInHtlcWeight returns the HTLC transaction weight for a static loop
// in with the given number of deposits.
func staticLoopInHtlcWeight(numDeposits int,
hasChange bool) lntypes.WeightUnit {
var estimator input.TxWeightEstimator
for range numDeposits {
estimator.AddTaprootKeySpendInput(txscript.SigHashDefault)
}
estimator.AddP2WSHOutput()
if hasChange {
estimator.AddP2TROutput()
}
return estimator.Weight()
}
// 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)
}
}

View file

@ -0,0 +1,477 @@
package liquidity
import (
"context"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
// TestStaticLoopInHtlcWeight verifies the exact HTLC transaction weight for
// the supported deposit-count and change-shape combinations.
func TestStaticLoopInHtlcWeight(t *testing.T) {
testCases := []struct {
name string
numDeposits int
hasChange bool
expected int64
}{
{
name: "zero deposits no change",
numDeposits: 0,
expected: 212,
},
{
name: "zero deposits with change",
numDeposits: 0,
hasChange: true,
expected: 384,
},
{
name: "single deposit no change",
numDeposits: 1,
expected: 444,
},
{
name: "multiple deposits with change",
numDeposits: 3,
hasChange: true,
expected: 1076,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
weight := staticLoopInHtlcWeight(
testCase.numDeposits, testCase.hasChange,
)
require.Equal(t, testCase.expected, int64(weight))
})
}
}
// TestStaticLoopInOnchainFee verifies that the HTLC publish fee and timeout
// sweep fee are combined correctly across the supported shapes.
func TestStaticLoopInOnchainFee(t *testing.T) {
testCases := []struct {
name string
numDeposits int
hasChange bool
htlcFeeRate chainfee.SatPerKWeight
timeoutSweepFeeRate chainfee.SatPerKWeight
expected btcutil.Amount
}{
{
name: "zero fee rates",
expected: 0,
},
{
name: "single deposit without change",
numDeposits: 1,
htlcFeeRate: chainfee.SatPerKWeight(1_200),
timeoutSweepFeeRate: chainfee.SatPerKWeight(800),
expected: 884,
},
{
name: "multiple deposits with change",
numDeposits: 3,
hasChange: true,
htlcFeeRate: chainfee.SatPerKWeight(2_500),
timeoutSweepFeeRate: chainfee.SatPerKWeight(1_700),
expected: 3439,
},
{
name: "zero htlc fee rate falls back to timeout fee " +
"rate",
numDeposits: 2,
htlcFeeRate: 0,
timeoutSweepFeeRate: chainfee.SatPerKWeight(1_100),
expected: chainfee.SatPerKWeight(1_100).FeeForWeight(
staticLoopInHtlcWeight(2, false),
) + loopInSweepFee(chainfee.SatPerKWeight(1_100)),
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
onchainFee := staticLoopInOnchainFee(
testCase.numDeposits, testCase.hasChange,
testCase.htlcFeeRate,
testCase.timeoutSweepFeeRate,
)
require.Equal(t, testCase.expected, onchainFee)
})
}
}
// TestStaticLoopInWorstCaseFees verifies that the helper chooses the larger
// of the cooperative success fee and timeout-path fee.
func TestStaticLoopInWorstCaseFees(t *testing.T) {
testCases := []struct {
name string
numDeposits int
hasChange bool
swapFee btcutil.Amount
htlcFeeRate chainfee.SatPerKWeight
timeoutSweepFeeRate chainfee.SatPerKWeight
expected btcutil.Amount
}{
{
name: "all fees zero",
expected: 0,
},
{
name: "returns success fee when larger",
numDeposits: 1,
swapFee: 5_000,
htlcFeeRate: chainfee.SatPerKWeight(800),
timeoutSweepFeeRate: chainfee.SatPerKWeight(700),
expected: 5_000,
},
{
name: "returns timeout fee when larger",
numDeposits: 2,
hasChange: true,
swapFee: 1_000,
htlcFeeRate: chainfee.SatPerKWeight(5_000),
timeoutSweepFeeRate: chainfee.SatPerKWeight(3_000),
expected: 5553,
},
{
name: "returns equal fee when paths match",
numDeposits: 1,
swapFee: 884,
htlcFeeRate: chainfee.SatPerKWeight(1_200),
timeoutSweepFeeRate: chainfee.SatPerKWeight(800),
expected: 884,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
require.Equal(
t, testCase.expected,
staticLoopInWorstCaseFees(
testCase.numDeposits,
testCase.hasChange,
testCase.swapFee, testCase.htlcFeeRate,
testCase.timeoutSweepFeeRate,
),
)
})
}
}
// TestCheckExistingAutoLoopsStatic verifies that static autoloops contribute
// to the same budget summary as legacy autoloops.
func TestCheckExistingAutoLoopsStatic(t *testing.T) {
ctx := t.Context()
sampleStaticLoopIns := []*StaticLoopInInfo{
{
Label: labels.AutoloopLabel(swap.TypeIn),
QuotedSwapFee: 50,
LastUpdateTime: testTime,
},
{
Label: labels.AutoloopLabel(swap.TypeIn),
QuotedSwapFee: 80,
LastUpdateTime: testTime,
Pending: true,
NumDeposits: 2,
},
{
Label: labels.AutoloopLabel(swap.TypeIn),
QuotedSwapFee: 70,
HtlcTxFeeRate: chainfee.SatPerKWeight(800),
LastUpdateTime: testTime,
Failed: true,
NumDeposits: 1,
},
}
cfg, _ := newTestConfig()
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo,
error) {
return sampleStaticLoopIns, nil
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.AutoloopBudgetLastRefresh = testBudgetStart
require.NoError(t, manager.setParameters(ctx, params))
summary, err := manager.checkExistingAutoLoops(ctx, nil, nil)
require.NoError(t, err)
require.Equal(t, 1, summary.inFlightCount)
require.Equal(
t,
btcutil.Amount(50)+staticLoopInWorstCaseFees(
1, false, 70, chainfee.SatPerKWeight(800),
defaultLoopInSweepFee,
),
summary.spentFees,
)
require.Equal(
t,
staticLoopInWorstCaseFees(
2, false, 80, 0,
defaultLoopInSweepFee,
),
summary.pendingFees,
)
}
// TestCurrentSwapTrafficStatic verifies that static loop-ins contribute peer
// blocking and failure backoff information to the shared traffic summary.
func TestCurrentSwapTrafficStatic(t *testing.T) {
ctx := t.Context()
cfg, _ := newTestConfig()
cfg.ListStaticLoopIn = func(context.Context) ([]*StaticLoopInInfo,
error) {
return []*StaticLoopInInfo{
{
LastHop: &peer1,
LastUpdateTime: testTime,
Pending: true,
BlocksLoopIn: true,
},
{
LastHop: &peer2,
LastUpdateTime: testTime,
Failed: true,
},
{
LastHop: &route.Vertex{3},
LastUpdateTime: testTime,
Pending: true,
BlocksLoopIn: false,
},
}, nil
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.FailureBackOff = time.Hour
require.NoError(t, manager.setParameters(ctx, params))
traffic, err := manager.currentSwapTraffic(ctx, nil, nil)
require.NoError(t, err)
require.True(t, traffic.ongoingLoopIn[peer1])
require.False(t, traffic.ongoingLoopIn[route.Vertex{3}])
require.Equal(t, testTime, traffic.failedLoopIn[peer2])
}
// TestSuggestSwapsStaticLoopInNoCandidate verifies that the planner surfaces a
// structured disqualification reason when static selection cannot build a
// full-deposit candidate for a peer rule.
func TestSuggestSwapsStaticLoopInNoCandidate(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(context.Context, route.Vertex,
btcutil.Amount, btcutil.Amount, string, string,
[]string) (*PreparedStaticLoopIn, error) {
return nil, ErrNoStaticLoopInCandidate
}
lnd.Channels = []lndclient.ChannelInfo{
{
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 1_000,
RemoteBalance: 9_000,
Capacity: 10_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.AutoloopBudgetLastRefresh = testBudgetStart
params.LoopInSource = LoopInSourceStaticAddress
params.PeerRules = map[route.Vertex]*SwapRule{
peer1: {
ThresholdRule: NewThresholdRule(0, 50),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
suggestions, err := manager.SuggestSwaps(ctx)
require.NoError(t, err)
require.Empty(t, suggestions.StaticInSwaps)
require.Equal(
t, ReasonStaticLoopInNoCandidate,
suggestions.DisqualifiedPeers[peer1],
)
}
// TestSuggestSwapsMixedInFlightCount verifies that static loop-ins consume the
// same accepted-suggestion slots as legacy swaps during final filtering.
func TestSuggestSwapsMixedInFlightCount(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
_, _ btcutil.Amount, label, initiator string,
_ []string) (*PreparedStaticLoopIn, error) {
return &PreparedStaticLoopIn{
Request: loop.StaticAddressLoopInRequest{
DepositOutpoints: []string{"static:0"},
SelectedAmount: 4_000,
MaxSwapFee: 20,
LastHop: &peer,
Label: label,
Initiator: initiator,
},
NumDeposits: 1,
}, nil
}
lnd.Channels = []lndclient.ChannelInfo{
channel1,
{
ChannelID: lnwire.NewShortChanIDFromInt(20).ToUint64(),
PubKeyBytes: peer2,
LocalBalance: 1_000,
RemoteBalance: 9_000,
Capacity: 10_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.AutoloopBudgetLastRefresh = testBudgetStart
params.MaxAutoInFlight = 1
params.FeeLimit = NewFeePortion(500000)
params.LoopInSource = LoopInSourceStaticAddress
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
}
params.PeerRules = map[route.Vertex]*SwapRule{
peer2: {
ThresholdRule: NewThresholdRule(0, 50),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
suggestions, err := manager.SuggestSwaps(ctx)
require.NoError(t, err)
require.Len(t, suggestions.OutSwaps, 1)
require.Empty(t, suggestions.StaticInSwaps)
require.Equal(t, ReasonInFlight, suggestions.DisqualifiedPeers[peer2])
}
// TestAutoLoopDispatchesStaticLoopIn verifies that the autoloop execution path
// dispatches prepared static loop-ins once they survive final filtering.
func TestAutoLoopDispatchesStaticLoopIn(t *testing.T) {
ctx := t.Context()
cfg, lnd := newTestConfig()
cfg.EnableStaticAddressAutoloop = true
var (
prepareCalls int
dispatched *loop.StaticAddressLoopInRequest
prepareInitiator string
)
cfg.PrepareStaticLoopIn = func(_ context.Context, peer route.Vertex,
minAmount, amount btcutil.Amount, label, initiator string,
excludedOutpoints []string) (*PreparedStaticLoopIn, error) {
prepareCalls++
prepareInitiator = initiator
require.Equal(t, peer1, peer)
require.Equal(t, testRestrictions.Minimum, minAmount)
require.Equal(t, testRestrictions.Maximum, amount)
require.Empty(t, excludedOutpoints)
return &PreparedStaticLoopIn{
Request: loop.StaticAddressLoopInRequest{
DepositOutpoints: []string{"static:0"},
SelectedAmount: testRestrictions.Maximum,
MaxSwapFee: 100,
LastHop: &peer,
Label: label,
Initiator: initiator,
},
NumDeposits: 1,
}, nil
}
cfg.StaticLoopIn = func(_ context.Context,
request *loop.StaticAddressLoopInRequest) (
*StaticLoopInDispatchResult, error) {
requestCopy := *request
dispatched = &requestCopy
return &StaticLoopInDispatchResult{
SwapHash: lntypes.Hash{1},
}, nil
}
lnd.Channels = []lndclient.ChannelInfo{
{
ChannelID: lnwire.NewShortChanIDFromInt(10).ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 0,
RemoteBalance: 100_000,
Capacity: 100_000,
},
}
manager := NewManager(cfg)
params := manager.GetParameters()
params.Autoloop = true
params.AutoFeeBudget = 100_000
params.AutoFeeRefreshPeriod = testBudgetRefresh
params.AutoloopBudgetLastRefresh = testBudgetStart
params.MaxAutoInFlight = 1
params.FailureBackOff = time.Hour
params.FeeLimit = NewFeePortion(500_000)
params.LoopInSource = LoopInSourceStaticAddress
params.PeerRules = map[route.Vertex]*SwapRule{
peer1: {
ThresholdRule: NewThresholdRule(0, 60),
Type: swap.TypeIn,
},
}
require.NoError(t, manager.setParameters(ctx, params))
err := manager.autoloop(ctx)
require.NoError(t, err)
require.Equal(t, 1, prepareCalls)
require.Equal(t, autoloopSwapInitiator, prepareInitiator)
require.NotNil(t, dispatched)
require.Equal(t, []string{"static:0"}, dispatched.DepositOutpoints)
require.Equal(t, testRestrictions.Maximum, dispatched.SelectedAmount)
require.Equal(
t, labels.AutoloopLabel(swap.TypeIn), dispatched.Label,
)
require.Equal(t, autoloopSwapInitiator, dispatched.Initiator)
require.NotNil(t, dispatched.LastHop)
require.Equal(t, peer1, *dispatched.LastHop)
}

View file

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

View file

@ -734,12 +734,16 @@ func (d *Daemon) initialize(withMacaroonService bool) error {
)
}
liquidityMgr := getLiquidityManager(
swapClient, staticLoopInManager, d.cfg.EnableExperimental,
)
// 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),

View file

@ -1395,6 +1395,18 @@ func (s *swapClientServer) SetLiquidityParams(ctx context.Context,
in *looprpc.SetLiquidityParamsRequest) (*looprpc.SetLiquidityParamsResponse,
error) {
enableExperimental := s.config != nil && s.config.EnableExperimental
params := in.GetParameters()
if params.GetLoopInSource() ==
looprpc.LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS &&
!enableExperimental {
return nil, status.Error(
codes.FailedPrecondition,
liquidity.ErrStaticAddressAutoloopExperimental.Error(),
)
}
err := s.liquidityMgr.SetParameters(ctx, in.Parameters)
if err != nil {
return nil, err
@ -1426,6 +1438,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 +1472,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 {
@ -2097,6 +2131,13 @@ func (s *swapClientServer) StaticAddressLoopIn(ctx context.Context,
Fast: in.Fast,
}
// External callers must not be able to use reserved autoloop labels.
// Internal autoloop dispatch bypasses this RPC and can still use the
// reserved labels needed to attribute automated swaps correctly.
if err := labels.Validate(req.Label); err != nil {
return nil, fmt.Errorf("invalid label: %w", err)
}
if in.LastHop != nil {
lastHop, err := route.NewVertexFromBytes(in.LastHop)
if err != nil {
@ -2409,6 +2450,13 @@ 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
case liquidity.ReasonCustomChannelData:
return looprpc.AutoReason_AUTO_REASON_CUSTOM_CHANNEL_DATA, nil
default:
return 0, fmt.Errorf("unknown autoloop reason: %v", reason)
}

View file

@ -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"
@ -27,6 +28,8 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
@ -262,6 +265,71 @@ 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())
}
// TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental verifies that
// users must restart loopd with --experimental before enabling static-address
// autoloop.
func TestSetLiquidityParamsRejectsStaticAutoloopWithoutExperimental(
t *testing.T) {
server := &swapClientServer{
config: &Config{},
}
_, err := server.SetLiquidityParams(
t.Context(), &looprpc.SetLiquidityParamsRequest{
Parameters: &looprpc.LiquidityParameters{
LoopInSource: looprpc.
LoopInSource_LOOP_IN_SOURCE_STATIC_ADDRESS,
},
},
)
require.Error(t, err)
require.Equal(t, codes.FailedPrecondition, status.Code(err))
require.ErrorContains(t, err, "--experimental")
}
// TestRPCAutoloopReasonStaticLoopInNoCandidate verifies that the new planner
// 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,
)
}
// 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) {

View file

@ -2,7 +2,9 @@ package loopd
import (
"context"
"errors"
"fmt"
"slices"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
@ -12,9 +14,11 @@ 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"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/ticker"
)
@ -115,7 +119,103 @@ 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,
enableExperimental bool) *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
}
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,
@ -150,6 +250,9 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager {
ListLoopOut: client.Store.FetchLoopOutSwaps,
GetLoopOut: client.Store.FetchLoopOutSwap,
ListLoopIn: client.Store.FetchLoopInSwaps,
ListStaticLoopIn: listStaticLoopIn,
PrepareStaticLoopIn: prepareStaticLoopIn,
StaticLoopIn: staticLoopIn,
LoopInTerms: client.LoopInTerms,
LoopOutTerms: client.LoopOutTerms,
GetAssetPrice: client.AssetClient.GetAssetPrice,
@ -157,6 +260,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager {
PutLiquidityParams: client.Store.PutLiquidityParams,
FetchLiquidityParams: client.Store.FetchLiquidityParams,
}
mngrCfg.EnableStaticAddressAutoloop = enableExperimental
return liquidity.NewManager(mngrCfg)
}

View file

@ -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
@ -376,6 +424,12 @@ 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
// 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.
@ -395,22 +449,26 @@ var (
11: "AUTO_REASON_LIQUIDITY_OK",
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,
"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,
"AUTO_REASON_CUSTOM_CHANNEL_DATA": 15,
}
)
@ -425,11 +483,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 +496,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 +588,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 +601,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 +664,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 +677,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 +716,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 +3513,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 +3739,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
@ -4024,6 +4091,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"`
@ -4075,6 +4144,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
@ -6732,7 +6808,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 +6837,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" +
@ -6786,10 +6863,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" +
@ -6980,10 +7058,13 @@ 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" +
"\tTHRESHOLD\x10\x01*\xf8\x03\n" +
"\n" +
"AutoReason\x12\x17\n" +
"\x13AUTO_REASON_UNKNOWN\x10\x00\x12\"\n" +
@ -7000,7 +7081,9 @@ 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\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" +
@ -7081,223 +7164,226 @@ 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
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() }
@ -7315,7 +7401,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,

View file

@ -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 {
@ -1526,6 +1543,18 @@ 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;
/*
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 {
@ -1556,6 +1585,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.

View file

@ -1543,10 +1543,12 @@
"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",
"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."
"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",
@ -2120,6 +2122,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 +2359,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": {
@ -2935,6 +2950,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": {

View file

@ -0,0 +1,96 @@
package loopin
import (
"context"
"errors"
"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) {
return selectNoChangeDepositsWithMemoryBudget(
maxAmount, minAmount, unfilteredDeposits, csvExpiry,
blockHeight, excludedOutpoints, autoloopDPMaxMemoryBytes,
)
}

View file

@ -0,0 +1,530 @@
package loopin
import (
"errors"
"math/bits"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/staticaddr/deposit"
)
const (
// autoloopDPMaxMemoryBytes caps the selector's working set. The
// selector compresses the sum space when needed so one planning tick
// cannot consume unbounded memory just because a node has many static
// deposits.
autoloopDPMaxMemoryBytes = 128 * 1024 * 1024
// autoloopDPStateOverheadBytes approximates the per-bucket cost outside
// of the bitset itself. The exact sum and count slices account for the
// logical state, and the extra slack keeps the sizing conservative so
// the selector stays below the intended memory budget in practice.
autoloopDPStateOverheadBytes = 16
)
var (
// errAutoloopDPMemoryBudgetTooSmall is returned when even the coarsest
// two-bucket table would exceed the configured memory budget. This is a
// structural limitation of the bounded-memory representation, not a
// liquidity constraint, so callers should not collapse it into
// ErrNoAutoloopCandidate.
errAutoloopDPMemoryBudgetTooSmall = errors.New(
"autoloop dp memory budget too small",
)
)
// autoloopCandidateDeposit carries the precomputed metadata the DP needs to
// make deterministic comparisons.
type autoloopCandidateDeposit struct {
// deposit is the original static-address deposit that may be selected.
deposit *deposit.Deposit
// residualLife is the remaining lifetime of the deposit in blocks once
// the current height is taken into account.
residualLife int64
// outpoint is cached so deterministic ordering does not keep rebuilding
// the string form during sort comparisons.
outpoint string
}
// autoloopDPTable stores one representative subset for each compressed sum
// bucket. Each representative carries its full bitset so later updates can
// compare candidates without relying on mutable predecessor buckets.
//
// This is intentionally heavier than a predecessor-only table. A simpler
// parent-pointer representation would be smaller per bucket, but it becomes
// incorrect once a source bucket is overwritten by a later update because
// already-derived states would silently change their parent chains.
//
// When the selector compresses the sum space, several exact sums can share one
// bucket. The build phase keeps only one representative for that bucket and
// prefers the larger exact sum before the final band scan considers expiry.
// That makes the compressed path approximate: an earlier-expiring smaller-sum
// subset can be hidden by a larger-sum subset in the same bucket. The default
// 128 MiB budget keeps realistic production inputs at step = 1, so this trade-
// off only matters when tests or future callers intentionally lower the memory
// budget.
type autoloopDPTable struct {
// wordsPerState is the number of 64-bit words needed to represent one
// deposit-selection bitset.
wordsPerState int
// exactSums stores the real satoshi sum of the representative subset in
// each bucket. The selector never trusts the compressed bucket weight
// for range checks because the DP is allowed to scale the sum space.
exactSums []btcutil.Amount
// counts stores the number of selected deposits for each bucket. A
// value of -1 marks an unreachable bucket.
counts []int32
// selections stores one flattened bitset per bucket. The bitset is the
// self-contained reconstruction data for the representative subset.
selections []uint64
}
// selectNoChangeDepositsWithMemoryBudget runs the bounded-memory selector with
// an explicit budget. Tests use this entry point to force the scaling path and
// to exercise hard budget failures deterministically.
func selectNoChangeDepositsWithMemoryBudget(maxAmount, minAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{},
maxMemoryBytes int) ([]*deposit.Deposit, error) {
eligibleDeposits, eligibleTotal := filterAutoloopCandidateDeposits(
maxAmount, unfilteredDeposits, csvExpiry, blockHeight,
excludedOutpoints,
)
if len(eligibleDeposits) == 0 || eligibleTotal < minAmount {
return nil, ErrNoAutoloopCandidate
}
step, bucketCount, err := autoloopDPSizing(
maxAmount, len(eligibleDeposits), maxMemoryBytes,
)
if err != nil {
return nil, err
}
table := newAutoloopDPTable(bucketCount, len(eligibleDeposits))
// The empty subset is the base state for all later transitions.
table.counts[0] = 0
for depositIndex, candidateDeposit := range eligibleDeposits {
weight := autoloopDPBucketWeight(
candidateDeposit.deposit.Value, step,
)
// Descending updates preserve the 0-1 constraint: every deposit
// is either present once in a candidate or not at all.
start := bucketCount - weight - 1
for sourceBucket := start; sourceBucket >= 0; sourceBucket-- {
if !table.isReachable(sourceBucket) {
continue
}
destBucket := sourceBucket + weight
candidateSum := table.exactSums[sourceBucket] +
candidateDeposit.deposit.Value
if candidateSum > maxAmount {
continue
}
beats := table.candidateBeatsState(
sourceBucket, destBucket, depositIndex,
candidateSum, eligibleDeposits,
)
if !beats {
continue
}
table.copyStateFromSource(
destBucket, sourceBucket, depositIndex,
candidateSum,
)
}
}
bestTotal := btcutil.Amount(-1)
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < minAmount || exactSum > maxAmount {
continue
}
if exactSum > bestTotal {
bestTotal = exactSum
}
}
if bestTotal < 0 {
return nil, ErrNoAutoloopCandidate
}
// The band lets expiry management influence the final choice, but only
// after the selector first learns the best liquidity amount that the
// compressed DP table can achieve. The slack gives back up to 25 percent
// of the gain above minAmount, not 25 percent of bestTotal itself.
slack := (bestTotal - minAmount) / 4
bandFloor := bestTotal - slack
bestBucket := -1
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < bandFloor || exactSum > bestTotal {
continue
}
if bestBucket == -1 {
bestBucket = bucket
continue
}
beats := table.stateBeatsStateWithinBand(
bucket, bestBucket, eligibleDeposits,
)
if beats {
bestBucket = bucket
}
}
if bestBucket == -1 {
return nil, ErrNoAutoloopCandidate
}
selectedIndices := table.selectedIndices(bestBucket)
selectedDeposits := make([]*deposit.Deposit, 0, len(selectedIndices))
for _, index := range selectedIndices {
selectedDeposits = append(
selectedDeposits, eligibleDeposits[index].deposit,
)
}
return selectedDeposits, nil
}
// filterAutoloopCandidateDeposits removes deposits that can never participate
// in a full-deposit static autoloop suggestion and sorts the remainder in the
// order used by the expiry comparisons.
func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{}) (
[]autoloopCandidateDeposit, btcutil.Amount) {
eligibleDeposits := make(
[]autoloopCandidateDeposit, 0, len(unfilteredDeposits),
)
var eligibleTotal btcutil.Amount
for _, candidateDeposit := range unfilteredDeposits {
outpoint := candidateDeposit.OutPoint.String()
if _, ok := excludedOutpoints[outpoint]; ok {
continue
}
swappable := IsSwappable(
uint32(candidateDeposit.ConfirmationHeight),
blockHeight, csvExpiry,
)
if !swappable {
continue
}
if candidateDeposit.Value > maxAmount {
continue
}
residualLife := candidateDeposit.ConfirmationHeight +
int64(csvExpiry) - int64(blockHeight)
eligibleDeposits = append(
eligibleDeposits, autoloopCandidateDeposit{
deposit: candidateDeposit,
residualLife: residualLife,
outpoint: outpoint,
},
)
eligibleTotal += candidateDeposit.Value
}
// The DP compares reconstructed residual-life sequences directly.
// Sorting deposits by residual life first keeps those later comparisons
// exact and deterministic without inventing a scalar "urgency score".
sort.Slice(eligibleDeposits, func(i, j int) bool {
left := eligibleDeposits[i]
right := eligibleDeposits[j]
switch {
case left.residualLife != right.residualLife:
return left.residualLife < right.residualLife
case left.deposit.Value != right.deposit.Value:
return left.deposit.Value > right.deposit.Value
}
return left.outpoint < right.outpoint
})
return eligibleDeposits, eligibleTotal
}
// autoloopDPSizing chooses the smallest bucket step that keeps the compressed
// table within the configured memory budget.
func autoloopDPSizing(maxAmount btcutil.Amount, depositCount,
maxMemoryBytes int) (btcutil.Amount, int, error) {
wordsPerState := autoloopDPWordsPerState(depositCount)
stateBytes := wordsPerState*8 + autoloopDPStateOverheadBytes
maxBuckets := maxMemoryBytes / stateBytes
// The selector needs at least bucket zero plus one positive bucket.
if maxBuckets < 2 {
return 0, 0, errAutoloopDPMemoryBudgetTooSmall
}
step := ceilAmountDiv(maxAmount, btcutil.Amount(maxBuckets-1))
step = max(step, 1)
bucketCount := int(ceilAmountDiv(maxAmount, step)) + 1
return step, bucketCount, nil
}
// autoloopDPWordsPerState returns the number of 64-bit words needed to encode
// one subset bitset for the current deposit count.
func autoloopDPWordsPerState(depositCount int) int {
return (depositCount + 63) / 64
}
// autoloopDPBucketWeight compresses a deposit value into one DP bucket weight.
//
// The table rounds down, but never below one bucket. Rounding up each deposit
// would accidentally reject some valid exact sums once several per-item
// round-up errors accumulate. The exact-sum guard remains the real safety
// boundary: compressed weights only decide which representative states are kept
// in memory, never whether a candidate is allowed to exceed maxAmount.
func autoloopDPBucketWeight(value, step btcutil.Amount) int {
weight := int(value / step)
if weight == 0 {
return 1
}
return weight
}
// ceilAmountDiv performs positive ceiling division for amount sizing.
func ceilAmountDiv(numerator, denominator btcutil.Amount) btcutil.Amount {
if numerator <= 0 {
return 0
}
return (numerator + denominator - 1) / denominator
}
// newAutoloopDPTable allocates the bounded-memory DP table.
func newAutoloopDPTable(bucketCount, depositCount int) *autoloopDPTable {
wordsPerState := autoloopDPWordsPerState(depositCount)
counts := make([]int32, bucketCount)
for i := range counts {
counts[i] = -1
}
return &autoloopDPTable{
wordsPerState: wordsPerState,
exactSums: make([]btcutil.Amount, bucketCount),
counts: counts,
selections: make([]uint64, bucketCount*wordsPerState),
}
}
// isReachable reports whether a bucket currently has a representative subset.
func (t *autoloopDPTable) isReachable(bucket int) bool {
return t.counts[bucket] >= 0
}
// stateWords returns the flattened bitset slice for one bucket.
func (t *autoloopDPTable) stateWords(bucket int) []uint64 {
start := bucket * t.wordsPerState
end := start + t.wordsPerState
return t.selections[start:end]
}
// copyStateFromSource writes a winning candidate into the destination bucket.
func (t *autoloopDPTable) copyStateFromSource(destBucket, sourceBucket,
depositIndex int, exactSum btcutil.Amount) {
destWords := t.stateWords(destBucket)
sourceWords := t.stateWords(sourceBucket)
copy(destWords, sourceWords)
wordIndex := depositIndex / 64
bitIndex := uint(depositIndex % 64)
destWords[wordIndex] |= uint64(1) << bitIndex
t.exactSums[destBucket] = exactSum
t.counts[destBucket] = t.counts[sourceBucket] + 1
}
// candidateBeatsState reports whether the candidate obtained by extending the
// source bucket with one deposit should replace the destination bucket.
func (t *autoloopDPTable) candidateBeatsState(sourceBucket, destBucket,
depositIndex int, candidateSum btcutil.Amount,
deposits []autoloopCandidateDeposit) bool {
if !t.isReachable(destBucket) {
return true
}
existingSum := t.exactSums[destBucket]
if candidateSum != existingSum {
return candidateSum > existingSum
}
candidateCount := int(t.counts[sourceBucket]) + 1
existingCount := int(t.counts[destBucket])
if candidateCount != existingCount {
return candidateCount < existingCount
}
// Only exact-sum and exact-count ties fall through to expiry
// comparison. That keeps the expensive residual-life reconstruction
// off the hot path.
return t.candidateEarlierThanState(
sourceBucket, destBucket, depositIndex, deposits,
)
}
// candidateEarlierThanState compares the candidate residual-life sequence to
// the existing destination sequence.
func (t *autoloopDPTable) candidateEarlierThanState(sourceBucket, destBucket,
depositIndex int, deposits []autoloopCandidateDeposit) bool {
candidateResidualLives := t.candidateResidualLives(
sourceBucket, depositIndex, deposits,
)
existingResidualLives := t.stateResidualLives(destBucket, deposits)
return compareResidualLifeSequences(
candidateResidualLives, existingResidualLives,
) < 0
}
// stateBeatsStateWithinBand applies the final band-local ordering:
// 1. earlier-expiring deposits.
// 2. larger exact total.
// 3. fewer deposits.
func (t *autoloopDPTable) stateBeatsStateWithinBand(leftBucket, rightBucket int,
deposits []autoloopCandidateDeposit) bool {
leftResidualLives := t.stateResidualLives(leftBucket, deposits)
rightResidualLives := t.stateResidualLives(rightBucket, deposits)
cmp := compareResidualLifeSequences(
leftResidualLives, rightResidualLives,
)
switch cmp {
case -1:
return true
case 1:
return false
}
leftSum := t.exactSums[leftBucket]
rightSum := t.exactSums[rightBucket]
if leftSum != rightSum {
return leftSum > rightSum
}
return t.counts[leftBucket] < t.counts[rightBucket]
}
// selectedIndices reconstructs the selected deposit indices for a bucket in
// sorted order. The caller must pass a reachable bucket.
func (t *autoloopDPTable) selectedIndices(bucket int) []int {
count := int(t.counts[bucket])
if count < 0 {
panic("selectedIndices called on unreachable bucket")
}
selectedIndices := make([]int, 0, count)
for wordIndex, word := range t.stateWords(bucket) {
for word != 0 {
bitIndex := bits.TrailingZeros64(word)
selectedIndices = append(
selectedIndices, wordIndex*64+bitIndex,
)
word &^= uint64(1) << uint(bitIndex)
}
}
return selectedIndices
}
// candidateResidualLives reconstructs the candidate residual-life sequence.
// The current deposit index is always larger than every index already present
// in the source bucket, so appending preserves the sorted order.
func (t *autoloopDPTable) candidateResidualLives(sourceBucket, depositIndex int,
deposits []autoloopCandidateDeposit) []int64 {
residualLives := t.stateResidualLives(sourceBucket, deposits)
residualLives = append(
residualLives, deposits[depositIndex].residualLife,
)
return residualLives
}
// stateResidualLives reconstructs the sorted residual-life sequence for a
// bucket.
func (t *autoloopDPTable) stateResidualLives(bucket int,
deposits []autoloopCandidateDeposit) []int64 {
selectedIndices := t.selectedIndices(bucket)
residualLives := make([]int64, 0, len(selectedIndices))
for _, index := range selectedIndices {
residualLives = append(
residualLives, deposits[index].residualLife,
)
}
return residualLives
}
// compareResidualLifeSequences compares two sorted residual-life sequences.
//
// A smaller residual-life value means the deposit expires sooner and is thus
// more urgent to consume. The comparison intentionally stops at the shorter
// length: if one sequence is a strict prefix of the other, expiry alone does
// not provide a principled winner and the caller falls back to amount and
// deposit-count tie-breakers instead of inventing an arbitrary preference for
// the longer or shorter set.
func compareResidualLifeSequences(left, right []int64) int {
limit := min(len(left), len(right))
for i := range limit {
switch {
case left[i] < right[i]:
return -1
case left[i] > right[i]:
return 1
}
}
return 0
}

View file

@ -0,0 +1,424 @@
package loopin
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/stretchr/testify/require"
)
// TestSelectNoChangeDepositsWithMemoryBudget covers the dp-specific behavior
// that the default helper does not expose directly: forced scaling and hard
// budget failures.
func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
t.Parallel()
depositSeven := makeDeposit(31, 0, 7_000, 240)
depositFour := makeDeposit(32, 0, 4_000, 241)
depositThree := makeDeposit(33, 0, 3_000, 242)
testCases := []struct {
name string
maxAmount btcutil.Amount
minAmount btcutil.Amount
deposits []*deposit.Deposit
maxMemory int
expected []*deposit.Deposit
expectedError error
}{
{
name: "a tight but sufficient budget forces scaled " +
"buckets and still finds the only valid subset",
maxAmount: 10_000,
minAmount: 9_000,
deposits: []*deposit.Deposit{
depositSeven, depositFour, depositThree,
},
maxMemory: 240,
expected: []*deposit.Deposit{
depositSeven, depositThree,
},
},
{
name: "a budget smaller than two state buckets fails " +
"explicitly instead of pretending no " +
"candidate exists",
maxAmount: 10_000,
minAmount: 9_000,
deposits: []*deposit.Deposit{
depositSeven, depositThree,
},
maxMemory: 23,
expectedError: errAutoloopDPMemoryBudgetTooSmall,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
deposits, err := selectNoChangeDepositsWithMemoryBudget(
testCase.maxAmount, testCase.minAmount,
testCase.deposits, 1_000, 100, nil,
testCase.maxMemory,
)
if testCase.expectedError != nil {
require.ErrorIs(t, err, testCase.expectedError)
require.Nil(t, deposits)
return
}
require.NoError(t, err)
require.Equal(
t, depositOutpoints(testCase.expected),
depositOutpoints(deposits),
)
})
}
}
// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
// to understand directly than by inferring the step from a larger selector
// behavior test.
func TestAutoloopDPSizing(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
maxAmount btcutil.Amount
depositCount int
maxMemoryBytes int
expectedStep btcutil.Amount
expectedBuckets int
expectedWords int
expectedError error
}{
{
name: "when the table fits exactly the step remains " +
"one",
maxAmount: 100,
depositCount: 1,
maxMemoryBytes: 24 * 200,
expectedStep: 1,
expectedBuckets: 101,
expectedWords: 1,
},
{
name: "when memory is tight the step increases just " +
"enough to stay inside budget",
maxAmount: 100,
depositCount: 64,
maxMemoryBytes: 24 * 11,
expectedStep: 10,
expectedBuckets: 11,
expectedWords: 1,
},
{
name: "when the budget cannot hold bucket zero and " +
"one positive bucket, sizing fails",
maxAmount: 100,
depositCount: 64,
maxMemoryBytes: 23,
expectedError: errAutoloopDPMemoryBudgetTooSmall,
expectedWords: 1,
},
{
name: "a non-positive max amount still rounds the " +
"step up to one after ceiling division " +
"returns zero",
maxAmount: 0,
depositCount: 64,
maxMemoryBytes: 24 * 10,
expectedStep: 1,
expectedBuckets: 1,
expectedWords: 1,
},
{
name: "when the deposit count exceeds one word the " +
"state size rounds up to the next word",
maxAmount: 100,
depositCount: 65,
maxMemoryBytes: 32 * 50,
expectedStep: 3,
expectedBuckets: 35,
expectedWords: 2,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
require.Equal(
t, testCase.expectedWords,
autoloopDPWordsPerState(testCase.depositCount),
)
step, bucketCount, err := autoloopDPSizing(
testCase.maxAmount, testCase.depositCount,
testCase.maxMemoryBytes,
)
if testCase.expectedError != nil {
require.ErrorIs(t, err, testCase.expectedError)
require.Zero(t, step)
require.Zero(t, bucketCount)
return
}
require.NoError(t, err)
require.Equal(t, testCase.expectedStep, step)
require.Equal(t, testCase.expectedBuckets, bucketCount)
})
}
}
// TestAutoloopDPBucketWeight verifies the compressed bucket mapping directly.
// The selector relies on this helper to avoid the round-up bug where several
// individually rounded deposits can make a valid exact sum unreachable.
func TestAutoloopDPBucketWeight(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
value btcutil.Amount
step btcutil.Amount
expected int
}{
{
name: "values larger than the step are truncated " +
"into the matching floor bucket",
value: 10,
step: 3,
expected: 3,
},
{
name: "values smaller than the step still consume " +
"one bucket so they remain selectable",
value: 2,
step: 5,
expected: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
require.Equal(
t, testCase.expected,
autoloopDPBucketWeight(
testCase.value, testCase.step,
),
)
})
}
}
// TestCompareResidualLifeSequences isolates the expiry-order helper. This is
// the selector's "what expires sooner?" rule, so the cases state explicitly
// why the helper should consider one sequence earlier, later, or tied.
func TestCompareResidualLifeSequences(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
left []int64
right []int64
expected int
}{
{
name: "the left sequence is earlier when the first " +
"differing deposit expires sooner",
left: []int64{100, 150},
right: []int64{100, 200},
expected: -1,
},
{
name: "the right sequence is earlier when its first " +
"differing deposit expires sooner",
left: []int64{150, 250},
right: []int64{150, 200},
expected: 1,
},
{
name: "a strict prefix is treated as an expiry tie " +
"so later amount and count rules can decide",
left: []int64{100},
right: []int64{100, 200},
expected: 0,
},
{
name: "the same strict-prefix rule applies " +
"regardless of which side is longer",
left: []int64{100, 200},
right: []int64{100},
expected: 0,
},
{
name: "identical residual-life sequences compare as " +
"equal",
left: []int64{100, 200},
right: []int64{100, 200},
expected: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
result := compareResidualLifeSequences(
testCase.left, testCase.right,
)
require.Equal(t, testCase.expected, result)
})
}
}
// TestFilterAutoloopCandidateDeposits covers the low-level filter and sort
// helper so selector tests do not have to infer ordering rules indirectly.
func TestFilterAutoloopCandidateDeposits(t *testing.T) {
t.Parallel()
earlierSameValue := makeDeposit(41, 0, 5_000, 200)
laterSameValueA := makeDeposit(42, 0, 5_000, 210)
laterSameValueB := makeDeposit(43, 0, 5_000, 210)
oversized := makeDeposit(44, 0, 9_000, 220)
unswappable := makeDeposit(45, 0, 4_000, 149)
selectedDeposits, total := filterAutoloopCandidateDeposits(
7_000,
[]*deposit.Deposit{
laterSameValueB, oversized, earlierSameValue,
unswappable, laterSameValueA,
},
1_000, 100, nil,
)
require.Equal(t, btcutil.Amount(15_000), total)
require.Equal(
t,
[]string{
earlierSameValue.OutPoint.String(),
laterSameValueA.OutPoint.String(),
laterSameValueB.OutPoint.String(),
},
candidateOutpoints(selectedDeposits),
)
}
// TestAutoloopDPComparators isolates helper branches that are awkward to hit
// predictably through the full selector alone.
func TestAutoloopDPComparators(t *testing.T) {
t.Parallel()
makeCandidateDeposits := func(
deposits ...*deposit.Deposit) []autoloopCandidateDeposit {
candidates := make(
[]autoloopCandidateDeposit, 0, len(deposits),
)
for _, deposit := range deposits {
candidate := autoloopCandidateDeposit{
deposit: deposit,
residualLife: deposit.ConfirmationHeight,
outpoint: deposit.OutPoint.String(),
}
candidates = append(candidates, candidate)
}
return candidates
}
t.Run("candidateBeatsState prefers a larger exact sum in the same "+
"bucket",
func(t *testing.T) {
t.Parallel()
small := makeDeposit(51, 0, 4_000, 300)
medium := makeDeposit(52, 0, 5_000, 301)
later := makeDeposit(53, 0, 1_000, 302)
candidates := makeCandidateDeposits(
small, medium, later,
)
table := newAutoloopDPTable(3, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, small.Value)
table.copyStateFromSource(2, 0, 1, medium.Value)
require.True(t, table.candidateBeatsState(
2, 1, 2, medium.Value+later.Value, candidates,
))
},
)
t.Run("stateBeatsStateWithinBand falls back to sum when expiry ties",
func(t *testing.T) {
t.Parallel()
six := makeDeposit(54, 0, 6_000, 300)
five := makeDeposit(55, 0, 5_000, 300)
candidates := makeCandidateDeposits(six, five)
table := newAutoloopDPTable(3, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, six.Value)
table.copyStateFromSource(2, 0, 1, five.Value)
require.True(t, table.stateBeatsStateWithinBand(
1, 2, candidates,
))
},
)
t.Run("stateBeatsStateWithinBand falls back to fewer deposits "+
"when expiry and sum tie",
func(t *testing.T) {
t.Parallel()
six := makeDeposit(56, 0, 6_000, 300)
five := makeDeposit(57, 0, 5_000, 300)
one := makeDeposit(58, 0, 1_000, 300)
candidates := makeCandidateDeposits(six, five, one)
table := newAutoloopDPTable(4, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, six.Value)
table.copyStateFromSource(2, 0, 1, five.Value)
table.copyStateFromSource(3, 2, 2, five.Value+one.Value)
require.True(t, table.stateBeatsStateWithinBand(
1, 3, candidates,
))
},
)
}
// depositOutpoints turns a deposit set into a stable, readable assertion
// surface for the selector tests.
func depositOutpoints(deposits []*deposit.Deposit) []string {
outpoints := make([]string, 0, len(deposits))
for _, selectedDeposit := range deposits {
outpoints = append(outpoints, selectedDeposit.OutPoint.String())
}
return outpoints
}
// candidateOutpoints exposes the filtered candidate order in a readable form.
func candidateOutpoints(
deposits []autoloopCandidateDeposit) []string {
outpoints := make([]string, 0, len(deposits))
for _, candidateDeposit := range deposits {
outpoints = append(outpoints, candidateDeposit.outpoint)
}
return outpoints
}

View file

@ -0,0 +1,377 @@
package loopin
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
// TestSelectNoChangeDeposits exercises the bounded-memory selector end to end.
// The full decision rule:
//
// 1. build only full-deposit, no-change candidates
// 2. find the best reachable total in the requested range
// 3. allow a band that gives back up to 25 percent of the gain above
// minAmount
// 4. inside that band, prefer earlier-expiring deposits
// 5. fall back to larger total, then fewer deposits
func TestSelectNoChangeDeposits(t *testing.T) {
depositSeven := makeDeposit(7, 0, 7_000, 200)
depositFour := makeDeposit(4, 0, 4_000, 210)
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)
depositTen := makeDeposit(23, 0, 10_000, 500)
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,
},
},
{
// A slightly smaller total can win when it stays inside
// the band that gives back only part of the gain above
// minAmount.
name: "smaller earlier-expiring combo can win " +
"inside band",
maxAmount: 10_000,
minAmount: 6_000,
deposits: []*deposit.Deposit{
depositTen, depositFive, depositFourC,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositFive, depositFourC,
},
},
{
name: "smaller earlier candidate below band does not " +
"beat best total",
maxAmount: 9_000,
minAmount: 8_000,
deposits: []*deposit.Deposit{
depositNine, depositSeven,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{depositNine},
},
{
name: "returns no candidate when enough value exists " +
"but no subset fits range",
maxAmount: 6_000,
minAmount: 5_000,
deposits: []*deposit.Deposit{
depositFourC, depositFourD, depositFourE,
},
csvExpiry: 1_000,
blockHeight: 100,
expectedErr: ErrNoAutoloopCandidate,
},
}
selectedOutpoints := func(deposits []*deposit.Deposit) []string {
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: &script.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: &script.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)
}

View file

@ -27,6 +27,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"
)
@ -105,6 +106,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
@ -486,6 +490,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()

View file

@ -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/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"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 {

View file

@ -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/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"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,50 @@ 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()
const confirmationHeight = 0
selectedDeposit := makeDeposit(1, 0, 9_000, confirmationHeight)
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 {
// activeDeposits is the set returned by GetActiveDepositsInState.
activeDeposits []*deposit.Deposit
// byOutpoint maps outpoint strings to deposits for direct lookups.
byOutpoint map[string]*deposit.Deposit
}
@ -187,10 +235,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) {
return nil, false
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 res, true
}
func (m *mockDepositManager) TransitionDeposits(_ context.Context,
@ -214,7 +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 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 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
m.lastHop = lastHop
m.initiator = initiator
m.numDeposits = numDeposits
m.fast = fast
if m.err != nil {
return nil, m.err
}
return m.quote, nil
}
// mockStore implements StaticAddressLoopInStore for tests.
@ -276,8 +387,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
@ -330,11 +447,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.

View file

@ -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

View file

@ -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,
)
}