askrene: add auto.include_fees layer
What changed, and why it matters
This commit adds a new optional routing layer called auto.include_fees to Core Lightning's askrene plugin. Normally, when sending a Lightning payment, the sender adds extra money on top of the requested amount to cover routing fees. With this new layer, the total amount sent is fixed, and the fees are subtracted from what the receiver gets instead. This is a deliberate feature change, not a bug fix, and it only affects routing calculations when a user explicitly requests the new layer. There is no direct security vulnerability in the code shown, but any mistake in fee arithmetic could in theory cause incorrect payment amounts.
No immediate security action required. Treat as a normal feature review: verify the new fee-deduction arithmetic is correct in all edge cases (MPP, sourcefree, zero-fee channels, overflow/underflow handling), and ensure documentation clearly warns users that the receiver receives less than the requested amount when auto.include_fees is used.
Security signals we found
New feature code, not a security patch
Fee arithmetic changed in routing path construction
No input validation, buffer, or cryptographic changes observed
No vendor security disclosure or CVE references present
Evidence from the diff
The commit introduces the auto.include_fees automatic layer for getroutes. When enabled, convert_flows_to_routes computes route hop amounts by fixing the source amount and deducting fees forward (using amount_msat_sub_fee) rather than adding fees backward from the delivery amount. The layer is accepted in param_layer_names and handled in apply_layers. Extensive Python tests verify expected amounts with and without auto.sourcefree. The change is feature code with no patch-style bounds checks, memory safety fixes, or cryptographic hardening visible in the diff.
Changed components
plugins/askrene/askrene.cdoc/schemas/getroutes.jsoncontrib/msggen/msggen/schema.jsontests/test_askrene.pyInspect captured patch +488 / −22
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 88049322..ca068e2f 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -15378,7 +15378,7 @@
"",
"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 three 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. And *auto.no_mpp_support* forces getroutes to return a single path solution which is useful for payments for which MPP is not supported."
+ "There are four 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.no_mpp_support* forces getroutes to return a single path solution which is useful for payments for which MPP is not supported. And *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender."
],
"categories": [
"readonly"
diff --git a/doc/schemas/getroutes.json b/doc/schemas/getroutes.json
index 6fc5020b..04f502e2 100644
--- a/doc/schemas/getroutes.json
+++ b/doc/schemas/getroutes.json
@@ -11,7 +11,7 @@
"",
"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 three 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. And *auto.no_mpp_support* forces getroutes to return a single path solution which is useful for payments for which MPP is not supported."
+ "There are four 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.no_mpp_support* forces getroutes to return a single path solution which is useful for payments for which MPP is not supported. And *auto.include_fees* that fixes the send amount and deducts fee from there, ie. the receiver pays for fees instead of the sender."
],
"categories": [
"readonly"
diff --git a/plugins/askrene/askrene.c b/plugins/askrene/askrene.c
index aeae4ffa..f3665a23 100644
--- a/plugins/askrene/askrene.c
+++ b/plugins/askrene/askrene.c
@@ -112,7 +112,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.no_mpp_support")
- || streq((*arr)[i], "auto.sourcefree"))
+ || streq((*arr)[i], "auto.sourcefree")
+ || streq((*arr)[i], "auto.include_fees"))
continue;
if (!find_layer(get_askrene(cmd->plugin), (*arr)[i])) {
return command_fail_badparam(cmd, name, buffer, t,
@@ -425,6 +426,12 @@ static void apply_layers(struct askrene *askrene, struct route_query *rq,
} else if (streq(layers[i], "auto.no_mpp_support")) {
plugin_log(rq->plugin, LOG_DBG, "Adding auto.no_mpp_support, sorry");
l = remove_small_channel_layer(layers, askrene, amount, localmods);
+ } else if (streq(layers[i], "auto.include_fees")) {
+ plugin_log(rq->plugin, LOG_DBG,
+ "Adding auto.include_fees");
+ /* This layer takes effect when converting flows
+ * into routes. */
+ continue;
} else {
assert(streq(layers[i], "auto.sourcefree"));
plugin_log(rq->plugin, LOG_DBG, "Adding auto.sourcefree");
@@ -447,7 +454,8 @@ static struct route **convert_flows_to_routes(const tal_t *ctx,
struct route_query *rq,
u32 finalcltv,
struct flow **flows,
- struct amount_msat **amounts)
+ struct amount_msat **amounts,
+ bool include_fees)
{
struct route **routes;
routes = tal_arr(ctx, struct route *, tal_count(flows));
@@ -462,27 +470,70 @@ static struct route **convert_flows_to_routes(const tal_t *ctx,
r->success_prob = flow_probability(flows[i], rq);
r->hops = tal_arr(r, struct route_hop, tal_count(flows[i]->path));
- /* Fill in backwards to calc amount and delay */
msat = flows[i]->delivers;
delay = finalcltv;
- for (int j = tal_count(flows[i]->path) - 1; j >= 0; j--) {
- struct route_hop *rh = &r->hops[j];
- struct gossmap_node *far_end;
- const struct half_chan *h = flow_edge(flows[i], j);
-
- if (!amount_msat_add_fee(&msat, h->base_fee, h->proportional_fee))
- plugin_err(rq->plugin, "Adding fee to amount");
- delay += h->delay;
-
- rh->scid = gossmap_chan_scid(rq->gossmap, flows[i]->path[j]);
- rh->direction = flows[i]->dirs[j];
- far_end = gossmap_nth_node(rq->gossmap, flows[i]->path[j], !flows[i]->dirs[j]);
- gossmap_node_get_id(rq->gossmap, far_end, &rh->node_id);
- rh->amount = msat;
- rh->delay = delay;
+ if (!include_fees) {
+ /* Fill in backwards to calc amount and delay */
+ for (int j = tal_count(flows[i]->path) - 1; j >= 0;
+ j--) {
+ struct route_hop *rh = &r->hops[j];
+ struct gossmap_node *far_end;
+ const struct half_chan *h =
+ flow_edge(flows[i], j);
+
+ if (!amount_msat_add_fee(&msat, h->base_fee,
+ h->proportional_fee))
+ plugin_err(rq->plugin,
+ "Adding fee to amount");
+ delay += h->delay;
+
+ rh->scid = gossmap_chan_scid(rq->gossmap,
+ flows[i]->path[j]);
+ rh->direction = flows[i]->dirs[j];
+ far_end = gossmap_nth_node(rq->gossmap,
+ flows[i]->path[j],
+ !flows[i]->dirs[j]);
+ gossmap_node_get_id(rq->gossmap, far_end,
+ &rh->node_id);
+ rh->amount = msat;
+ rh->delay = delay;
+ }
+ (*amounts)[i] = flows[i]->delivers;
+ } else {
+ /* Fill in backwards to calc delay */
+ for (int j = tal_count(flows[i]->path) - 1; j >= 0;
+ j--) {
+ struct route_hop *rh = &r->hops[j];
+ struct gossmap_node *far_end;
+ const struct half_chan *h =
+ flow_edge(flows[i], j);
+
+ delay += h->delay;
+
+ rh->scid = gossmap_chan_scid(rq->gossmap,
+ flows[i]->path[j]);
+ rh->direction = flows[i]->dirs[j];
+ far_end = gossmap_nth_node(rq->gossmap,
+ flows[i]->path[j],
+ !flows[i]->dirs[j]);
+ gossmap_node_get_id(rq->gossmap, far_end,
+ &rh->node_id);
+ rh->delay = delay;
+ }
+ /* Compute fees forward */
+ for (int j = 0; j < tal_count(flows[i]->path); j++) {
+ struct route_hop *rh = &r->hops[j];
+ const struct half_chan *h =
+ flow_edge(flows[i], j);
+
+ rh->amount = msat;
+ msat = amount_msat_sub_fee(msat, h->base_fee,
+ h->proportional_fee);
+ }
+ (*amounts)[i] = msat;
}
- (*amounts)[i] = flows[i]->delivers;
+
rq_log(tmpctx, rq, LOG_INFORM, "Flow %zu/%zu: %s",
i, tal_count(flows),
fmt_route(tmpctx, r, (*amounts)[i], finalcltv));
@@ -574,6 +625,7 @@ static struct command_result *do_getroutes(struct command *cmd,
struct flow **flows;
struct json_stream *response;
const struct gossmap_node *me;
+ bool include_fees;
/* update the gossmap */
if (gossmap_refresh(askrene->gossmap)) {
@@ -709,9 +761,11 @@ static struct command_result *do_getroutes(struct command *cmd,
rq_log(tmpctx, rq, LOG_DBG, "Final answer has %zu flows",
tal_count(flows));
+ include_fees = have_layer(info->layers, "auto.include_fees");
+
/* convert flows to routes */
routes = convert_flows_to_routes(rq, rq, info->finalcltv, flows,
- &amounts);
+ &amounts, include_fees);
assert(tal_count(routes) == tal_count(flows));
assert(tal_count(amounts) == tal_count(flows));
diff --git a/tests/test_askrene.py b/tests/test_askrene.py
index d7babfbf..77fa3159 100644
--- a/tests/test_askrene.py
+++ b/tests/test_askrene.py
@@ -2020,3 +2020,415 @@ def test_excessive_fee_cost(node_factory):
maxfee_msat=1000,
final_cltv=5,
)
+
+
+def check_getroute_routes(
+ node,
+ source,
+ destination,
+ amount_msat,
+ routes,
+ layers=[],
+ maxfee_msat=1000,
+ final_cltv=99,
+):
+ """Check that routes are as expected in result. This is similar to
+ check_getroute_paths but it compares all fields inside the routes and not
+ just the path."""
+
+ def check_route_as_expected(routes, expected_routes):
+ """Make sure all fields in paths are match those in routes"""
+
+ def dict_subset_eq(a, b):
+ """Is every key in B is the same in A?"""
+ return all(a.get(key) == b[key] for key in b if key != "path")
+
+ for r in expected_routes:
+ found = False
+ for i, candidate in enumerate(routes):
+ pathlen = len(r["path"])
+ if len(candidate["path"]) != pathlen:
+ continue
+ if dict_subset_eq(candidate, r) and all(
+ dict_subset_eq(candidate["path"][i], r["path"][i])
+ for i in range(pathlen)
+ ):
+ del routes[i]
+ found = True
+ break
+
+ if not found:
+ raise ValueError(
+ "Could not find route {} in routes {}".format(r, routes)
+ )
+
+ if routes != []:
+ raise ValueError("Did not expect paths {}".format(routes))
+
+ getroutes = node.rpc.getroutes(
+ source=source,
+ destination=destination,
+ amount_msat=amount_msat,
+ layers=layers,
+ maxfee_msat=maxfee_msat,
+ final_cltv=final_cltv,
+ )
+ assert getroutes["probability_ppm"] <= 1000000
+ check_route_as_expected(getroutes["routes"], routes)
+
+
+def test_includefees(node_factory):
+ """Test the amounts in the hops is set correctly when we use
+ auto.include_fees layer."""
+ gsfile, nodemap = generate_gossip_store(
+ [
+ GenChannel(
+ 0,
+ 1,
+ capacity_sats=1000000,
+ forward=GenChannel.Half(basefee=1, propfee=10000, delay=5),
+ ),
+ GenChannel(
+ 1,
+ 2,
+ capacity_sats=1000000,
+ forward=GenChannel.Half(basefee=2, propfee=20000, delay=5),
+ ),
+ GenChannel(
+ 2,
+ 3,
+ capacity_sats=1000000,
+ forward=GenChannel.Half(basefee=3, propfee=30000, delay=5),
+ ),
+ ]
+ )
+ l1 = node_factory.get_node(gossip_store_file=gsfile.name)
+
+ # Check computed fees in normal mode
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[1],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1011,
+ "delay": 99 + 5,
+ }
+ ],
+ }
+ ],
+ layers=[],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[2],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1033,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1022,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=[],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[3],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1066,
+ "delay": 99 + 5 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1055,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "2x3x2/0",
+ "next_node_id": nodemap[3],
+ "amount_msat": 1033,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=[],
+ )
+
+ # Check computed fees in include_fees mode
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[1],
+ 1000,
+ [
+ # we compute a route for 1000msat and the recepient receives 990
+ {
+ "amount_msat": 990,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99 + 5,
+ }
+ ],
+ }
+ ],
+ layers=["auto.include_fees"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[2],
+ 1000,
+ [
+ {
+ "amount_msat": 969,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 990,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.include_fees"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[3],
+ 1000,
+ [
+ {
+ "amount_msat": 938,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99 + 5 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 990,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "2x3x2/0",
+ "next_node_id": nodemap[3],
+ "amount_msat": 969,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.include_fees"],
+ )
+
+ # Normal mode combined with "auto.sourcefree"
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[1],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99,
+ }
+ ],
+ }
+ ],
+ layers=["auto.sourcefree"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[2],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1022,
+ "delay": 99 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1022,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.sourcefree"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[3],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1055,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1055,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "2x3x2/0",
+ "next_node_id": nodemap[3],
+ "amount_msat": 1033,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.sourcefree"],
+ )
+
+ # "auto.include_fees" mode combined with "auto.sourcefree"
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[1],
+ 1000,
+ [
+ {
+ "amount_msat": 1000,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99,
+ }
+ ],
+ }
+ ],
+ layers=["auto.sourcefree", "auto.include_fees"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[2],
+ 1000,
+ [
+ {
+ "amount_msat": 979,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1000,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.sourcefree", "auto.include_fees"],
+ )
+
+ check_getroute_routes(
+ l1,
+ nodemap[0],
+ nodemap[3],
+ 1000,
+ [
+ {
+ "amount_msat": 948,
+ "path": [
+ {
+ "short_channel_id_dir": "0x1x0/1",
+ "next_node_id": nodemap[1],
+ "amount_msat": 1000,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "1x2x1/1",
+ "next_node_id": nodemap[2],
+ "amount_msat": 1000,
+ "delay": 99 + 5 + 5,
+ },
+ {
+ "short_channel_id_dir": "2x3x2/0",
+ "next_node_id": nodemap[3],
+ "amount_msat": 979,
+ "delay": 99 + 5,
+ },
+ ],
+ }
+ ],
+ layers=["auto.sourcefree", "auto.include_fees"],
+ )
Why this scored 21/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.