diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index d5eb12795c53..9676fb02e785 100644 --- a/contrib/msggen/msggen/schema.json +++ b/contrib/msggen/msggen/schema.json @@ -17105,7 +17105,11 @@ "", "Layers are generally maintained by plugins, either to contain persistent information about capacities which have been discovered, or to contain transient information for this particular payment (such as blinded paths or routehints).", "", - "There are some automatic layers: *auto.localchans* contains information on local channels from this node (including non-public ones), and their exact current spendable capacities. *auto.sourcefree* overrides all channels (including those from previous layers) leading out of the *source* to be zero fee and zero delay. These are both useful in the case where the source is the current node. *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender. *auto.no_mpp_support* is deprecated in v26.06: use `maxparts=1` instead." + "There are some automatic layers: *auto.localchans* contains information on local channels from this node (including non-public ones), and their exact current spendable capacities. *auto.sourcefree* overrides all channels (including those from previous layers) leading out of the *source* to be zero fee and zero delay. These are both useful in the case where the source is the current node. *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender. *auto.allow_circular* permits *source* to equal *destination*, requesting a circular (self-rebalance) route: see below; it grants permission rather than changing behavior, so it has no effect when *source* and *destination* differ. *auto.no_mpp_support* is deprecated in v26.06: use `maxparts=1` instead.", + "", + "Circular (self-rebalance) routing: normally *source* and *destination* must differ, and *source* == *destination* is rejected as invalid input. If the *auto.allow_circular* layer is included in *layers*, *source* == *destination* is instead treated as a request for a self-rebalance cycle: a route that leaves *source*, traverses the network, and returns to *source*. This is intended for moving liquidity between one's own channels. Combine it with a layer that disables every (*source* -> peer) direction except the channels you want the cycle to drain, and every (peer -> *source*) direction except the channels you want it to fill, to pin both ends of the cycle.", + "", + "In a circular result, the final hop of each route is the real return channel: its *short_channel_id_dir* is the (peer -> *source*) channel direction the cycle returns through, its *node_id_out* is *source* itself, and its *amount_out_msat* is the amount delivered back to *source*. Routes are directly usable for building the payment; no placeholder substitution is required." ], "categories": [ "readonly" diff --git a/doc/schemas/getroutes.json b/doc/schemas/getroutes.json index 5b02d8afd080..66dab12f6bbf 100644 --- a/doc/schemas/getroutes.json +++ b/doc/schemas/getroutes.json @@ -11,7 +11,11 @@ "", "Layers are generally maintained by plugins, either to contain persistent information about capacities which have been discovered, or to contain transient information for this particular payment (such as blinded paths or routehints).", "", - "There are some automatic layers: *auto.localchans* contains information on local channels from this node (including non-public ones), and their exact current spendable capacities. *auto.sourcefree* overrides all channels (including those from previous layers) leading out of the *source* to be zero fee and zero delay. These are both useful in the case where the source is the current node. *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender. *auto.no_mpp_support* is deprecated in v26.06: use `maxparts=1` instead." + "There are some automatic layers: *auto.localchans* contains information on local channels from this node (including non-public ones), and their exact current spendable capacities. *auto.sourcefree* overrides all channels (including those from previous layers) leading out of the *source* to be zero fee and zero delay. These are both useful in the case where the source is the current node. *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender. *auto.allow_circular* permits *source* to equal *destination*, requesting a circular (self-rebalance) route: see below; it grants permission rather than changing behavior, so it has no effect when *source* and *destination* differ. *auto.no_mpp_support* is deprecated in v26.06: use `maxparts=1` instead.", + "", + "Circular (self-rebalance) routing: normally *source* and *destination* must differ, and *source* == *destination* is rejected as invalid input. If the *auto.allow_circular* layer is included in *layers*, *source* == *destination* is instead treated as a request for a self-rebalance cycle: a route that leaves *source*, traverses the network, and returns to *source*. This is intended for moving liquidity between one's own channels. Combine it with a layer that disables every (*source* -> peer) direction except the channels you want the cycle to drain, and every (peer -> *source*) direction except the channels you want it to fill, to pin both ends of the cycle.", + "", + "In a circular result, the final hop of each route is the real return channel: its *short_channel_id_dir* is the (peer -> *source*) channel direction the cycle returns through, its *node_id_out* is *source* itself, and its *amount_out_msat* is the amount delivered back to *source*. Routes are directly usable for building the payment; no placeholder substitution is required." ], "categories": [ "readonly" diff --git a/plugins/askrene/askrene.c b/plugins/askrene/askrene.c index 70247c1ea189..3f440c4c6583 100644 --- a/plugins/askrene/askrene.c +++ b/plugins/askrene/askrene.c @@ -115,7 +115,8 @@ static struct command_result *param_layer_names(struct command *cmd, /* Must be a known layer name */ if (streq((*arr)[i], "auto.localchans") || streq((*arr)[i], "auto.sourcefree") - || streq((*arr)[i], "auto.include_fees")) + || streq((*arr)[i], "auto.include_fees") + || streq((*arr)[i], "auto.allow_circular")) continue; if (streq((*arr)[i], "auto.no_mpp_support") @@ -359,8 +360,223 @@ struct getroutes_info { /* Non-NULL if we are told to use "auto.localchans" */ struct layer *local_layer; u32 maxparts; + /* Circular routing (self-rebalance) support. + * + * When the caller passes source == destination (and opts in via + * the auto.allow_circular layer), we splice a node-split into this + * request's + * per-request localmods: + * - Synthesise a fake "us_in" destination node. + * - For every (peer -> source) direction still enabled + * after the caller's layers have been applied, disable + * that real direction and add a mirror (peer -> us_in) + * channel with matching properties. + * + * The MCF then sees a regular s -> t flow problem with + * source -> ... -> peer -> us_in. No direct edge connects + * source to us_in, so the algorithm has to traverse the + * network. Algorithms, flow extraction, and routing-cost + * code run unchanged. + * + * After flow conversion the trailing (peer -> us_in) hop is + * KEPT in each returned route -- its amount_out_msat is the + * actually-delivered amount -- and is translated back to real + * terms at serialization time via `circular_unsplit` below: + * the caller sees the real (peer -> source) return channel + * and their own node id, ready for building sendpay. + * + * Existing callers (source != destination) bypass all of + * this -- do_getroutes only calls inject_circular_fake() + * for source == destination. */ + + /* Fake-mirror-scid -> real-channel translation table, built by + * inject_circular_fake() and applied by the solver child when + * serializing routes. NULL for normal requests. */ + struct circular_unsplit *circular_unsplit; +}; + +/* Hardcoded fake "us_in" node id for circular routing. Must not + * collide with any real Lightning node id likely to appear in + * gossmap. Lives only in the per-request localmods; collision + * within a single request would be a real-world fluke. Distinct + * from renepay's fake destination (which ends in 0x01). */ +static const struct node_id circular_fake_us_in_id = {{ + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, +}}; + +/* Snapshot of a real (peer -> source) channel we want to mirror. + * Collected while the user's layers are applied to the gossmap, so + * we see the post-mask view and only mirror what the caller has + * left enabled. */ +struct circular_mirror { + /* The real (peer -> source) channel direction, to disable. */ + struct short_channel_id_dir real_scidd; + /* Peer at the far end of the real channel. */ + struct node_id peer_id; + /* Channel properties to copy onto the fake mirror. */ + struct amount_msat capacity; + struct amount_msat htlc_min, htlc_max; + struct amount_msat base_fee; + u32 fee_proportional_millionths; + u16 cltv_delta; }; +/* If source == destination (a self-rebalance call), perform + * node-splitting at the gossmap layer: + * + * 1. Apply the caller's layers to the gossmap temporarily so we + * see the post-mask view (i.e. which peer -> source channels + * the caller has left enabled). + * 2. For each still-enabled (peer -> source) direction: + * - Disable the real direction in localmods. + * - Add a mirror (peer -> circular_fake_us_in_id) channel + * with a unique fake scid and the real channel's + * properties copied over. + * 3. Undo the temporary apply, leaving the augmented localmods + * ready for the regular gossmap_apply_localmods() at the end + * of do_getroutes. + * 4. Replace info->dest with circular_fake_us_in_id. + * 5. Record each fake scid -> real channel mapping in + * info->circular_unsplit so the solver child translates the + * final hop of each route back to real terms when + * serializing (see struct circular_unsplit in child/child.h). + * + * Only called for source == destination (do_getroutes guards the + * call). Returns NULL on success, or an error string suitable for + * failing the command. */ +static const char *inject_circular_fake(struct getroutes_info *info, + struct gossmap *gossmap, + struct gossmap_localmods *localmods) +{ + /* Temporary apply so we can read the user's post-mask view. */ + gossmap_apply_localmods(gossmap, localmods); + + const struct gossmap_node *me = + gossmap_find_node(gossmap, &info->source); + + struct circular_mirror *mirrors = + tal_arr(tmpctx, struct circular_mirror, 0); + + if (me) { + for (size_t i = 0; i < me->num_chans; i++) { + int us_half; + const struct gossmap_chan *c = + gossmap_nth_chan(gossmap, me, i, &us_half); + const int peer_to_us_dir = !us_half; + + /* Skip directions the caller has masked off. */ + if (!c->half[peer_to_us_dir].enabled) + continue; + + struct circular_mirror m; + m.real_scidd.scid = gossmap_chan_scid(gossmap, c); + m.real_scidd.dir = peer_to_us_dir; + + /* gossmap_nth_node(c, dir) returns the SENDER of + * direction dir. We want the peer at the far end + * of the channel -- i.e. the sender of the + * (peer -> us) direction. Using us_half here + * would return us, not the peer. */ + const struct gossmap_node *peer = + gossmap_nth_node(gossmap, c, peer_to_us_dir); + gossmap_node_get_id(gossmap, peer, &m.peer_id); + + m.capacity = gossmap_chan_get_capacity(gossmap, c); + m.htlc_min = + gossmap_chan_htlc_min(c, peer_to_us_dir); + m.htlc_max = + gossmap_chan_htlc_max(c, peer_to_us_dir); + m.base_fee = + amount_msat(c->half[peer_to_us_dir].base_fee); + m.fee_proportional_millionths = + c->half[peer_to_us_dir].proportional_fee; + m.cltv_delta = c->half[peer_to_us_dir].delay; + + tal_arr_expand(&mirrors, m); + } + } + + /* Undo the temporary apply -- the next apply at do_getroutes + * line ~620 will reapply with our augmentations included. */ + gossmap_remove_localmods(gossmap, localmods); + + /* Add disable + mirror entries to localmods, recording each + * fake scid -> real channel mapping so the solver child can + * translate the final hop back when serializing routes. */ + struct circular_unsplit *unsplit = tal(info, struct circular_unsplit); + unsplit->source = info->source; + unsplit->entries = tal_arr(unsplit, struct circular_unsplit_entry, 0); + + u32 fake_tx = 0; + for (size_t i = 0; i < tal_count(mirrors); i++) { + const struct circular_mirror *m = &mirrors[i]; + + /* Disable the real (peer -> source) direction: its inbound + * flow now has to arrive via the mirror into us_in. */ + const bool real_enabled = false; + gossmap_local_updatechan(localmods, &m->real_scidd, + &real_enabled, + NULL, NULL, NULL, NULL, NULL); + + /* Fake scids count up from 0x0x0. Block 0 cannot hold a + * real mainnet channel, but synthetic gossmaps (and the + * caller's layers, via created channels) can occupy any + * scid, and a clash corrupts that channel instead of + * creating the mirror. Step past scids present in the + * gossmap; gossmap_local_addchan refuses duplicates + * within localmods, covering created channels. */ + struct short_channel_id fake_scid; + bool added = false; + while (!added && fake_tx <= 0xFFFFFF) { + if (!mk_short_channel_id(&fake_scid, 0, fake_tx++, 0)) + break; + if (gossmap_find_chan(gossmap, &fake_scid)) + continue; + added = gossmap_local_addchan(localmods, &m->peer_id, + &circular_fake_us_in_id, + fake_scid, m->capacity, + NULL); + } + if (!added) + return tal_fmt(tmpctx, + "Could not allocate a fake scid to mirror %s", + fmt_short_channel_id_dir(tmpctx, + &m->real_scidd)); + + const struct short_channel_id_dir mirror_scidd = { + .scid = fake_scid, + .dir = node_id_cmp(&m->peer_id, + &circular_fake_us_in_id) < 0 + ? 0 : 1, + }; + const bool mirror_enabled = true; + if (!gossmap_local_updatechan(localmods, &mirror_scidd, + &mirror_enabled, + &m->htlc_min, &m->htlc_max, + &m->base_fee, + &m->fee_proportional_millionths, + &m->cltv_delta)) + return tal_fmt(tmpctx, + "Could not set mirror properties for %s", + fmt_short_channel_id_dir(tmpctx, + &m->real_scidd)); + + const struct circular_unsplit_entry entry = { + .fake_scid = fake_scid, + .real = m->real_scidd, + }; + tal_arr_expand(&unsplit->entries, entry); + } + + info->dest = circular_fake_us_in_id; + info->circular_unsplit = unsplit; + return NULL; +} + static void add_layer(const struct layer ***layers, const struct layer *l, const struct gossmap *gossmap, @@ -402,6 +618,11 @@ static const struct layer **apply_layers(const tal_t *ctx, /* This layer takes effect when converting flows * into routes. */ continue; + } else if (streq(layernames[i], "auto.allow_circular")) { + /* This layer takes effect at request setup + * (see inject_circular_fake); nothing to + * apply to the gossmap here. */ + continue; } else { assert(streq(layernames[i], "auto.sourcefree")); cmd_log(tmpctx, cmd, LOG_DBG, "Adding auto.sourcefree"); @@ -616,6 +837,32 @@ static struct command_result *do_getroutes(struct command *cmd, reserves_clear_capacities(askrene->reserved, askrene->gossmap, capacities); + /* Self-rebalance handling: when source == destination, splice + * a fake "us_in" destination node into localmods and mirror + * the still-enabled (peer -> source) directions onto it. + * Algorithms see a regular s -> t flow problem and run + * unchanged. Must precede gossmap_apply_localmods so the + * augmented mods are picked up by the apply below. Only + * reachable when the caller passed the auto.allow_circular + * layer (json_getroutes rejects source == destination + * otherwise). */ + if (node_id_eq(&info->source, &info->dest)) { + cmd_log(tmpctx, cmd, LOG_DBG, + "source == destination: circular (self-rebalance) " + "route requested"); + err = inject_circular_fake(info, askrene->gossmap, localmods); + if (err) { + /* localmods are not applied at this point, so fail + * directly: the fail: path would try to remove + * them. */ + return command_fail(cmd, PAY_ROUTE_NOT_FOUND, + "%s", err); + } + cmd_log(tmpctx, cmd, LOG_DBG, + "Circular routing: spliced fake us_in destination " + "node, mirrored peer -> source channels into it"); + } + /* we temporarily apply localmods */ gossmap_apply_localmods(askrene->gossmap, localmods); @@ -722,6 +969,7 @@ static struct command_result *do_getroutes(struct command *cmd, include_next_node_id, include_amount_msat, include_delay, + info->circular_unsplit, replyfds[1]); abort(); } @@ -943,9 +1191,17 @@ static struct command_result *json_getroutes(struct command *cmd, maxdelay_allowed); } - if (node_id_eq(source, dest)) { + /* source == destination is invalid input for a normal route, and + * we reject it as such -- unless the caller explicitly opts in to + * circular (self-rebalance) routing via the auto.allow_circular + * layer, in which case we splice a node-split in do_getroutes + * (see inject_circular_fake()) and return a cycle back to source. */ + if (node_id_eq(source, dest) + && !have_layer(info->layers, "auto.allow_circular")) { return command_fail(cmd, JSONRPC2_INVALID_PARAMS, - "source and destination must be different"); + "source and destination must be different" + " (add the auto.allow_circular layer for" + " a self-rebalance route)"); } if (command_check_only(cmd)) @@ -961,6 +1217,8 @@ static struct command_result *json_getroutes(struct command *cmd, info->dev_algo = *dev_algo; info->additional_costs = new_htable(info, additional_cost_htable); info->maxparts = *maxparts; + /* Set by inject_circular_fake() for circular requests. */ + info->circular_unsplit = NULL; if (askrene->num_live_requests >= askrene->max_children) { cmd_log(tmpctx, cmd, LOG_INFORM, diff --git a/plugins/askrene/child/child.c b/plugins/askrene/child/child.c index 72aecd68dfd8..3fe86b87a614 100644 --- a/plugins/askrene/child/child.c +++ b/plugins/askrene/child/child.c @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -139,12 +138,33 @@ static struct route **convert_flows_to_routes(const tal_t *ctx, return routes; } +/* Circular requests route to a synthetic destination over fake mirror + * channels (see struct circular_unsplit in child.h): rewrite such a + * hop to the real (peer -> source) channel and the real source id. + * Only final hops can match, by construction. */ +static void maybe_unsplit_hop(const struct circular_unsplit *unsplit, + struct short_channel_id_dir *scidd, + struct node_id *node_out) +{ + if (!unsplit) + return; + for (size_t i = 0; i < tal_count(unsplit->entries); i++) { + if (!short_channel_id_eq(scidd->scid, + unsplit->entries[i].fake_scid)) + continue; + *scidd = unsplit->entries[i].real; + *node_out = unsplit->source; + return; + } +} + static void json_add_getroutes(struct json_stream *js, struct route **routes, double probability, bool include_next_node_id, bool include_amount_msat, - bool include_delay) + bool include_delay, + const struct circular_unsplit *unsplit) { json_add_u64(js, "probability_ppm", (u64)(probability * 1000000)); json_array_start(js, "routes"); @@ -158,18 +178,22 @@ static void json_add_getroutes(struct json_stream *js, json_array_start(js, "path"); for (size_t j = 0; j < tal_count(routes[i]->hops); j++) { const struct hop *hop = &routes[i]->hops[j]; + struct short_channel_id_dir scidd = hop->scidd; + struct node_id node_out = hop->node_out; + + maybe_unsplit_hop(unsplit, &scidd, &node_out); json_object_start(js, NULL); json_add_short_channel_id_dir( - js, "short_channel_id_dir", hop->scidd); + js, "short_channel_id_dir", scidd); json_add_node_id(js, "node_id_in", &hop->node_in); - json_add_node_id(js, "node_id_out", &hop->node_out); + json_add_node_id(js, "node_id_out", &node_out); json_add_amount_msat(js, "amount_in_msat", hop->amount_in); json_add_amount_msat(js, "amount_out_msat", hop->amount_out); json_add_u32(js, "cltv_in", hop->cltv_value_in); json_add_u32(js, "cltv_out", hop->cltv_value_out); if (include_next_node_id) - json_add_node_id(js, "next_node_id", &hop->node_out); + json_add_node_id(js, "next_node_id", &node_out); if (include_amount_msat) json_add_amount_msat(js, "amount_msat", hop->amount_in); if (include_delay) @@ -226,6 +250,7 @@ void run_child(const struct gossmap *gossmap, bool include_next_node_id, bool include_amount_msat, bool include_delay, + const struct circular_unsplit *unsplit, int replyfd) { double probability; @@ -276,7 +301,8 @@ void run_child(const struct gossmap *gossmap, json_add_getroutes(js, routes, probability, include_next_node_id, include_amount_msat, - include_delay); + include_delay, + unsplit); /* Detach filter before it complains about closing object it never saw */ if (cmd_filter) { diff --git a/plugins/askrene/child/child.h b/plugins/askrene/child/child.h index 5cb6688e52be..3a3e6409c98a 100644 --- a/plugins/askrene/child/child.h +++ b/plugins/askrene/child/child.h @@ -1,11 +1,13 @@ #ifndef LIGHTNING_PLUGINS_ASKRENE_CHILD_CHILD_H #define LIGHTNING_PLUGINS_ASKRENE_CHILD_CHILD_H #include "config.h" +#include #include #include #include #include #include +#include #include struct additional_cost_htable; @@ -15,6 +17,26 @@ struct json_filter; struct layer; struct reserve_htable; +/* For circular (self-rebalance) requests the parent splits the source + * node in two before forking (see inject_circular_fake in askrene.c): + * each still-enabled (peer -> source) channel direction is disabled + * and mirrored, under a fake scid, into a synthetic destination node. + * This table maps the synthetic side back to reality when serializing + * routes: a hop over a mirror's fake scid is rewritten to the real + * (peer -> source) channel, and its node_id_out becomes the real + * source id. NULL for normal (non-circular) requests. */ +struct circular_unsplit_entry { + struct short_channel_id fake_scid; + struct short_channel_id_dir real; +}; + +struct circular_unsplit { + /* The real source (== destination) node id. */ + struct node_id source; + /* tal array: one entry per mirrored channel direction. */ + struct circular_unsplit_entry *entries; +}; + /* This is the child. Do the thing. */ void run_child(const struct gossmap *gossmap, const struct layer **layers, @@ -34,6 +56,7 @@ void run_child(const struct gossmap *gossmap, bool include_next_node_id, bool include_amount_msat, bool include_delay, + const struct circular_unsplit *unsplit, int reply_fd) NORETURN; #endif /* LIGHTNING_PLUGINS_ASKRENE_CHILD_CHILD_H */ diff --git a/tests/test_askrene.py b/tests/test_askrene.py index 4474ec321dfe..f62248286289 100644 --- a/tests/test_askrene.py +++ b/tests/test_askrene.py @@ -849,6 +849,214 @@ def test_getroutes(node_factory): 'cltv_out': 99}]]) +def test_getroutes_circular(node_factory): + """Test getroutes for a circular (source == destination) self-rebalance. + + source == destination is rejected as invalid input unless the + caller opts in with the auto.allow_circular layer. When opted in, the + circular-askrene node-split splices a synthetic "us_in" node into + the gossmap so the MCF sees a regular source -> us_in flow. Every + returned route KEEPS its final hop, translated back to real + terms: its short_channel_id_dir is the real (peer -> source) + return channel the cycle comes home through, node_id_out is + source itself, and amount_out_msat is the delivered amount. + Routes are directly usable for building sendpay. + + Topology -- a single deterministic cycle 0 -> 1 -> 2 -> 0: + chan A 0<->1 : only 0->1 enabled (source's sole out edge) + chan B 1<->2 : 1->2 enabled (middle hop) + chan C 2<->0 : only 2->0 enabled (peer -> source, gets mirrored) + A's reverse (1->0) is disabled so there is no shorter 0 -> 1 -> 0 + cycle; C's reverse (0->2) is disabled so 0->1 is the only source out + edge. Net result: exactly one path. + + """ + gsfile, nodemap = generate_gossip_store( + [GenChannel(0, 1, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False)), + GenChannel(1, 2, + forward=GenChannel.Half(enabled=True)), + GenChannel(2, 0, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False))]) + + l1 = node_factory.get_node(gossip_store_file=gsfile.name) + + amount = 100000 + final_cltv = 99 + + # Without the auto.allow_circular layer, source == destination is + # rejected outright. + with pytest.raises(RpcError, match=r"source and destination must be different"): + l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[0], + amount_msat=amount, + layers=[], + maxfee_msat=100000, + final_cltv=final_cltv) + + res = l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[0], + amount_msat=amount, + layers=['auto.allow_circular'], + maxfee_msat=100000, + final_cltv=final_cltv) + + # One deterministic path; the whole amount is delivered back to us. + assert len(res['routes']) == 1 + route = res['routes'][0] + assert route['amount_msat'] == amount + assert route['final_cltv'] == final_cltv + + # Pin the path by node id, not by short_channel_id_dir: the synthetic + # scids generate_gossip_store assigns to the real hops (e.g. "2x2x1") + # are a harness-internal detail, not part of what the circular split is + # being tested for. + path = route['path'] + assert len(path) == 3 + + # Hop 0: us -> peer 1 (our real outgoing channel). + assert path[0]['node_id_in'] == nodemap[0] + assert path[0]['node_id_out'] == nodemap[1] + + # Hop 1: peer 1 -> peer 2 (real middle hop). + assert path[1]['node_id_in'] == nodemap[1] + assert path[1]['node_id_out'] == nodemap[2] + + # Hop 2: the final hop, translated back from the synthetic mirror -- + # the crux of the circular split. node_id_out is us, the scid is the + # REAL (2 -> 0) return channel the cycle comes home through, and + # amount_out_msat is the amount delivered back to us. + chan_c = only_one([c for c in l1.rpc.listchannels(source=nodemap[2])['channels'] + if c['destination'] == nodemap[0]]) + chan_c_scidd = f"{chan_c['short_channel_id']}/{chan_c['direction']}" + final_hop = path[2] + assert final_hop['node_id_in'] == nodemap[2] + assert final_hop['node_id_out'] == nodemap[0] + assert final_hop['short_channel_id_dir'] == chan_c_scidd + assert final_hop['amount_out_msat'] == amount + assert final_hop['cltv_out'] == final_cltv + + # The mirror-scid allocator must step past occupied scids rather + # than corrupt the occupant: a layer-created channel squats on + # 0x0x0, and this synthetic store puts a REAL channel at 0x1x0 + # (gossmap-compress assigns block = chan_index + node1, so channel + # A gets block 0), forcing the allocator to 0x2x0. A clash would + # silently update the occupant instead of creating the mirror, + # corrupting the route -- so the route surviving intact, with its + # final hop still translated to the real channel, is the check. + # The squat channel has no channel updates, so it is disabled and + # cannot perturb the route itself. + l1.rpc.askrene_create_layer('squat') + l1.rpc.askrene_create_channel('squat', + nodemap[1], + nodemap[2], + '0x0x0', + '1000000sat') + res = l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[0], + amount_msat=amount, + layers=['squat', 'auto.allow_circular'], + maxfee_msat=100000, + final_cltv=final_cltv) + assert len(res['routes']) == 1 + final_hop = res['routes'][0]['path'][-1] + assert final_hop['node_id_out'] == nodemap[0] + assert final_hop['short_channel_id_dir'] == chan_c_scidd + assert final_hop['amount_out_msat'] == amount + + +def test_getroutes_circular_multi_inbound(node_factory): + """Circular route splitting across TWO inbound channels from the SAME + final peer: each returned route's final hop must carry the correct + distinct real channel. This is the case the caller could not resolve + before final hops were translated back from the synthetic mirrors -- + channel properties can tie, and only askrene knows the mapping. + + Topology: 0 -> 1 -> 2 with ample capacity, then two small channels + 2 <-> 0 (only 2->0 enabled). The amount exceeds either small channel + alone, so the flow must split and return through both. + """ + gsfile, nodemap = generate_gossip_store( + [GenChannel(0, 1, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False), + capacity_sats=100_000), + GenChannel(1, 2, capacity_sats=100_000), + GenChannel(2, 0, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False), + capacity_sats=8_000), + GenChannel(2, 0, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False), + capacity_sats=8_000)]) + + l1 = node_factory.get_node(gossip_store_file=gsfile.name) + + # More than either 8000-sat inbound channel can carry alone. + amount = 10_000_000 + + res = l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[0], + amount_msat=amount, + layers=['auto.allow_circular'], + maxfee_msat=1_000_000, + final_cltv=99) + routes = res['routes'] + assert len(routes) == 2 + + # The two real (2 -> 0) channels. + inbound = {f"{c['short_channel_id']}/{c['direction']}" + for c in l1.rpc.listchannels(source=nodemap[2])['channels'] + if c['destination'] == nodemap[0]} + assert len(inbound) == 2 + + # Each route returns over a distinct real inbound channel, back at + # us, and together they deliver the full amount. + return_scidds = set() + delivered = 0 + for r in routes: + final_hop = r['path'][-1] + assert final_hop['node_id_out'] == nodemap[0] + return_scidds.add(final_hop['short_channel_id_dir']) + delivered += final_hop['amount_out_msat'] + assert return_scidds == inbound + assert delivered == amount + + +def test_getroutes_circular_layer_noop(node_factory): + """auto.allow_circular grants permission, it does not change behavior: + a normal (source != destination) request must return the identical + result with and without the layer.""" + gsfile, nodemap = generate_gossip_store( + [GenChannel(0, 1, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False)), + GenChannel(1, 2, + forward=GenChannel.Half(enabled=True)), + GenChannel(2, 0, + forward=GenChannel.Half(enabled=True), + reverse=GenChannel.Half(enabled=False))]) + + l1 = node_factory.get_node(gossip_store_file=gsfile.name) + + without_layer = l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[2], + amount_msat=100000, + layers=[], + maxfee_msat=100000, + final_cltv=99) + with_layer = l1.rpc.getroutes(source=nodemap[0], + destination=nodemap[2], + amount_msat=100000, + layers=['auto.allow_circular'], + maxfee_msat=100000, + final_cltv=99) + assert with_layer == without_layer + + def test_getroutes_single_path(node_factory): """Test getroutes generating single path payments""" gsfile, nodemap = generate_gossip_store( @@ -1601,6 +1809,96 @@ def test_real_data(node_factory, bitcoind, executor): assert l1.daemon.is_in_log(r"Too many running at once \(4 vs 4\): waiting") +@pytest.mark.slow_test +def test_real_data_circular(node_factory, bitcoind): + """Masked circular self-rebalance over a real mainnet gossmap snapshot. + + Mask the source node down to a single drain (source-out) channel + and a single distinct fill (dest-in) channel, then ask getroutes + for a self-rebalance (source == destination, auto.allow_circular). + The mask is the point -- without it the cheapest "cycle" + degenerates to an out-and-back on one channel; you have to + constrain BOTH the one out and the one in. + + Because exactly one out and one in survive the mask, the route is + deterministic at the endpoints: every part must leave via the drain + channel and return via the fill channel, whose real scid appears + (translated back from the internal mirror) as the final hop. + Run against several real hubs. + + """ + outfile = tempfile.NamedTemporaryFile(prefix='gossip-store-') + nodeids = subprocess.check_output(['devtools/gossmap-compress', + 'decompress', + 'tests/data/gossip-store-2026-02-03.compressed', + outfile.name]).decode('utf-8').splitlines() + + l1 = node_factory.get_node(gossip_store_file=outfile.name, + allow_warning=True, + options={'askrene-timeout': TIMEOUT}) + + AMOUNT = 10_000_000 + WANT = 3 + routed = 0 + for n in range(min(50, len(nodeids))): + node = nodeids[n] + chans = l1.rpc.listchannels(source=node)['channels'] + # Need two distinct channels: one to drain, one to fill. + if len(chans) < 2: + continue + + src, dst = chans[0], chans[1] + layer = f'circular-{n}' + l1.rpc.askrene_create_layer(layer) + + # Mask all but one source-out and one dest-in: a single if per + # direction inside the channel loop. out_dir is node -> peer; + # 1 - out_dir is peer -> node (the direction askrene mirrors). + for c in chans: + scid = c['short_channel_id'] + out_dir = c['direction'] + if scid != src['short_channel_id']: + l1.rpc.askrene_update_channel(layer=layer, + short_channel_id_dir=f"{scid}/{out_dir}", + enabled=False) + if scid != dst['short_channel_id']: + l1.rpc.askrene_update_channel(layer=layer, + short_channel_id_dir=f"{scid}/{1 - out_dir}", + enabled=False) + + try: + routes = l1.rpc.getroutes(source=node, + destination=node, + amount_msat=AMOUNT, + layers=[layer, 'auto.allow_circular'], + maxfee_msat=AMOUNT, + final_cltv=18) + except RpcError: + # Drain peer can't reach fill peer within budget: try next node. + continue + + routed += 1 + delivered = 0 + for r in routes['routes']: + # Forced to leave via the drain channel... + assert r['path'][0]['short_channel_id_dir'] == f"{src['short_channel_id']}/{src['direction']}" + assert r['path'][0]['node_id_in'] == node + assert r['path'][0]['node_id_out'] == src['destination'] + # ...and return via the real fill channel, back to us. + assert r['path'][-1]['short_channel_id_dir'] == \ + f"{dst['short_channel_id']}/{1 - dst['direction']}" + assert r['path'][-1]['node_id_out'] == node + assert r['path'][-1]['node_id_in'] == dst['destination'] + delivered += r['path'][-1]['amount_out_msat'] + # The parts together deliver the whole amount back to us. + assert delivered == AMOUNT + + if routed >= WANT: + break + + assert routed > 0 + + @pytest.mark.slow_test def test_real_biases(node_factory, bitcoind, executor): # Route from Rusty's node to the top 100.