lightningd: honor `payment-fronting-node` when making bolt11 invoices.
What changed, and why it matters
This commit fixes a bug where Core Lightning's `payment-fronting-node` configuration option was ignored when creating BOLT11 invoices. The option lets a node designate one or more 'fronting' nodes that should always appear as the first hop in invoice route hints, even if those channels would normally be skipped as private, offline, or low-capacity. The change makes invoice generation honor that setting so the intended routing path is advertised.
Review whether bypassing offline and zero-capacity checks for fronting nodes is intentional and safe; ensure documentation for `payment-fronting-node` warns that it can expose channel information and route payments through channels that would otherwise be omitted. No immediate security patch appears required, but operators should understand the privacy/routing implications.
Security signals we found
Behavioral change in route-hint selection that could expose otherwise private/offline channels when `payment-fronting-node` is set
Bypass of offline-channel and zero-capacity filters for configured fronting nodes
Addition of regression test for the fronting-node invoice behavior
Evidence from the diff
The patch adds select_inchan_all() in lightningd/invoice.c and, when fronting_nodes is configured, uses all matching candidates as route hints instead of the existing reservoir-sampling logic. In lightningd/routehint.c, it adds is_fronting_node() and treats fronting nodes as public, skips the zero-capacity dead-end check, and skips the offline-owner check for fronting channels. A regression test in tests/test_invoices.py verifies that invoices from nodes configured with --payment-fronting-node include the specified fronting nodes as route hints and that payments succeed.
Changed components
lightningd/invoice.clightningd/routehint.ctests/test_invoices.pyInspect captured patch +78 / −5
diff --git a/lightningd/invoice.c b/lightningd/invoice.c
index 6ddecd4d..1dfb31c3 100644
--- a/lightningd/invoice.c
+++ b/lightningd/invoice.c
@@ -660,6 +660,25 @@ static struct route_info **select_inchan_mpp(const tal_t *ctx,
return routehints;
}
+static struct route_info **select_inchan_all(const tal_t *ctx,
+ struct lightningd *ld,
+ struct routehint_candidate
+ *candidates)
+{
+ struct route_info **routehints;
+
+ log_debug(ld->log, "Selecting all %zu candidates",
+ tal_count(candidates));
+
+ routehints = tal_arr(ctx, struct route_info *, tal_count(candidates));
+ for (size_t i = 0; i < tal_count(candidates); i++) {
+ routehints[i] = tal_dup(routehints, struct route_info,
+ candidates[i].r);
+ }
+
+ return routehints;
+}
+
/* Encapsulating struct while we wait for gossipd to give us incoming channels */
struct chanhints {
bool expose_all_private;
@@ -721,6 +740,10 @@ add_routehints(struct invoice_info *info,
needed = info->b11->msat ? *info->b11->msat : AMOUNT_MSAT(1);
+ /* --payment-fronting-node means use all candidates. */
+ if (tal_count(info->cmd->ld->fronting_nodes))
+ info->b11->routes = select_inchan_all(info->b11, info->cmd->ld, candidates);
+
/* If we are not completely unpublished, try with reservoir
* sampling first.
*
@@ -736,7 +759,7 @@ add_routehints(struct invoice_info *info,
* should make an effort to avoid overlapping incoming
* channels, which is done by select_inchan_mpp.
*/
- if (!node_unpublished)
+ else if (!node_unpublished)
info->b11->routes = select_inchan(info->b11,
info->cmd->ld,
needed,
diff --git a/lightningd/routehint.c b/lightningd/routehint.c
index 27264f65..b3144f3a 100644
--- a/lightningd/routehint.c
+++ b/lightningd/routehint.c
@@ -13,6 +13,16 @@ static bool scid_in_arr(const struct short_channel_id *scidarr,
return false;
}
+static bool is_fronting_node(const struct lightningd *ld,
+ const struct node_id *node)
+{
+ for (size_t i = 0; i < tal_count(ld->fronting_nodes); i++) {
+ if (node_id_eq(&ld->fronting_nodes[i], node))
+ return true;
+ }
+ return false;
+}
+
struct routehint_candidate *
routehint_candidates(const tal_t *ctx,
struct lightningd *ld,
@@ -58,7 +68,7 @@ routehint_candidates(const tal_t *ctx,
struct routehint_candidate candidate;
struct amount_msat fee_base, htlc_max;
struct route_info *r;
- bool is_public;
+ bool is_public, is_fronting;
r = tal(tmpctx, struct route_info);
@@ -88,6 +98,17 @@ routehint_candidates(const tal_t *ctx,
json_tok_full(buf, toks));
}
+ /* If they specify fronting nodes, always use them. */
+ if (tal_count(ld->fronting_nodes)) {
+ if (!is_fronting_node(ld, &r->pubkey)) {
+ log_debug(ld->log, "%s: not a fronting node",
+ fmt_node_id(tmpctx, &r->pubkey));
+ continue;
+ }
+ is_fronting = true;
+ } else
+ is_fronting = false;
+
/* Note: listincoming returns real scid or local alias if no real scid. */
candidate.c = any_channel_by_scid(ld, r->short_channel_id, true);
if (!candidate.c) {
@@ -129,6 +150,10 @@ routehint_candidates(const tal_t *ctx,
if (expose_all_private != NULL && *expose_all_private)
is_public = true;
+ /* Also, consider fronting nodes public */
+ if (is_fronting)
+ is_public = true;
+
r->fee_base_msat = fee_base.millisatoshis; /* Raw: route_info */
/* Could wrap: if so ignore */
if (!amount_msat_eq(amount_msat(r->fee_base_msat), fee_base)) {
@@ -152,7 +177,7 @@ routehint_candidates(const tal_t *ctx,
continue;
}
/* If they give us a hint, we use even if capacity 0 */
- } else if (amount_msat_is_zero(capacity)) {
+ } else if (!is_fronting && amount_msat_is_zero(capacity)) {
log_debug(ld->log, "%s: deadend",
fmt_short_channel_id(tmpctx,
r->short_channel_id));
@@ -162,8 +187,8 @@ routehint_candidates(const tal_t *ctx,
continue;
}
- /* Is it offline? */
- if (candidate.c->owner == NULL) {
+ /* Is it offline? Leave it if it's fronting. */
+ if (!is_fronting && candidate.c->owner == NULL) {
log_debug(ld->log, "%s: offline",
fmt_short_channel_id(tmpctx,
r->short_channel_id));
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index c7d9187f..f174af55 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -942,6 +942,31 @@ def test_invoice_botched_migration(node_factory, chainparams):
assert l1.rpc.invoice(100, "test", "test")["created_index"] == 3
+def test_payment_fronting(node_factory):
+ l1, l2 = node_factory.get_nodes(2)
+ l3, l4 = node_factory.get_nodes(2, opts=[{'payment-fronting-node': l1.info['id']},
+ {'payment-fronting-node': [l1.info['id'], l2.info['id']]}])
+
+ assert l3.rpc.listconfigs('payment-fronting-node') == {'configs': {'payment-fronting-node': {'sources': ['cmdline'], 'values_str': [l1.info['id']]}}}
+ assert l4.rpc.listconfigs('payment-fronting-node') == {'configs': {'payment-fronting-node': {'sources': ['cmdline', 'cmdline'], 'values_str': [l1.info['id'], l2.info['id']]}}}
+
+ # l1 <----> l3
+ # \
+ # \-----> l4 <----> l2
+ node_factory.join_nodes([l1, l3], wait_for_announce=True)
+ node_factory.join_nodes([l1, l4], wait_for_announce=True)
+ node_factory.join_nodes([l2, l4], wait_for_announce=True)
+
+ l3inv = l3.rpc.invoice(1000, 'l3inv', 'l3inv')['bolt11']
+ assert only_one(only_one(l3.rpc.decode(l3inv)['routes']))['pubkey'] == l1.info['id']
+
+ l4inv = l4.rpc.invoice(1000, 'l4inv', 'l4inv')['bolt11']
+ assert [only_one(r)['pubkey'] for r in l4.rpc.decode(l4inv)['routes']] == [l1.info['id'], l2.info['id']]
+
+ l1.rpc.xpay(l3inv)
+ l1.rpc.xpay(l4inv)
+
+
def test_invoice_maxdesc(node_factory, chainparams):
l1, l2 = node_factory.line_graph(2)
Why this scored 30/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.