offers: when creating an invoice for an offer, only reuse fronting paths.
What changed, and why it matters
This change fixes a bug in how Core Lightning creates invoices from offers. Previously, the software always reused the hidden routing paths (blinded paths) from the original offer when making the invoice. That could force payments through nodes that lacked enough capacity, or through paths chosen before the final amount was known. Now it only forces reuse of those paths when the user explicitly configured them as 'fronting nodes.' Otherwise it picks a better path when the invoice is created. Two previously failing tests are re-enabled, suggesting the bug caused real payment failures.
Treat as a reliability and minor security fix worth including in the next release. Review related BOLT 12 invoice path-selection code to ensure no other unconditional reuse of offer paths remains. Run the re-enabled tests (test_blinded_path_max, test_xpay_simple) to confirm stability.
Security signals we found
Fixes a functional bug that could cause invoice creation to select blinded paths with insufficient HTLC capacity
Removes two xfail markers from payment tests, showing the change resolves previously known failures
Changes path-selection logic for BOLT 12 offers/invoices, which affects payment privacy and reliability
No explicit security framing by the author; described as a correctness/consistency fix
Evidence from the diff
The patch modifies plugins/offers_invreq_hook.c so that invoice creation checks a new force_paths flag on the offer. If force_paths is true (set by the offer command’s fronting_nodes option or the payment-fronting-node config option), the code still extracts and reuses usable fronting nodes from the offer’s blinded paths. If force_paths is false, it falls back to the node’s default fronting_nodes, letting add_blindedpaths select a suitable path at invoice time. The change removes the unconditional reuse of offer_paths and deletes the now-unnecessary ‘self-paths are not fronting nodes’ fallback. Two xfail markers in tests/test_pay.py and tests/test_xpay.py are removed, indicating the underlying failures are resolved.
Changed components
plugins/offers_invreq_hook.ctests/test_pay.pytests/test_xpay.pyBOLT 12 offer/invoice request handlingBlinded path selection for invoicesInspect captured patch +40 / −32
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index ad598daa..597fcc9a 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -829,12 +829,11 @@ static struct command_result *listoffers_done(struct command *cmd,
{
const struct offers_data *od = get_offers_data(cmd->plugin);
const jsmntok_t *arr = json_get_member(buf, result, "offers");
- const jsmntok_t *offertok, *activetok, *b12tok;
- bool active;
+ const jsmntok_t *offertok, *activetok, *b12tok, *forcetok;
+ bool active, force_paths;
struct command_result *err;
struct amount_msat amt;
struct tlv_invoice_request_invreq_recurrence_cancel *cancel;
- struct pubkey *offer_fronts;
/* BOLT #12:
*
@@ -920,34 +919,46 @@ static struct command_result *listoffers_done(struct command *cmd,
return fail_invreq(cmd, ir, "Offer expired");
}
- /* If offer used fronting nodes, we use them too. */
- offer_fronts = tal_arr(ir, struct pubkey, 0);
- for (size_t i = 0; i < tal_count(ir->invreq->offer_paths); i++) {
- const struct blinded_path *p = ir->invreq->offer_paths[i];
- struct sciddir_or_pubkey first = p->first_node_id;
-
- /* In dev mode we could set this. Ignore if we can't map */
- if (!first.is_pubkey && !gossmap_scidd_pubkey(get_gossmap(cmd->plugin), &first)) {
- plugin_log(cmd->plugin, LOG_UNUSUAL,
- "Can't find front %s, ignoring in %s",
- fmt_sciddir_or_pubkey(tmpctx, &p->first_node_id),
- invrequest_encode(tmpctx, ir->invreq));
- continue;
- }
- assert(first.is_pubkey);
- /* Self-paths are not fronting nodes */
- if (!pubkey_eq(&od->id, &first.pubkey))
- tal_arr_expand(&offer_fronts, first.pubkey);
+ /* If offer specifically used fronting nodes, we use them for
+ * invoice, too. */
+ forcetok = json_get_member(buf, offertok, "force_paths");
+ if (!forcetok) {
+ return fail_internalerr(cmd, ir,
+ "Missing force_paths: %.*s",
+ json_tok_full_len(offertok),
+ json_tok_full(buf, offertok));
}
- if (tal_count(offer_fronts) != 0)
- ir->fronting_nodes = offer_fronts;
- else {
- /* Get upset if none from offer (via invreq) were usable! */
- if (tal_count(ir->invreq->offer_paths) != 0)
- return fail_invreq(cmd, ir, "Fronting failed, could not find any fronts");
+ json_to_bool(buf, forcetok, &force_paths);
+ if (force_paths) {
+ /* Gather usable subset of offer fronts */
+ struct pubkey *offer_fronts;
+
+ offer_fronts = tal_arr(ir, struct pubkey, 0);
+ for (size_t i = 0; i < tal_count(ir->invreq->offer_paths); i++) {
+ const struct blinded_path *p = ir->invreq->offer_paths[i];
+ struct sciddir_or_pubkey first = p->first_node_id;
+
+ /* In dev mode we could set this. Ignore if we can't map */
+ if (!first.is_pubkey && !gossmap_scidd_pubkey(get_gossmap(cmd->plugin), &first)) {
+ plugin_log(cmd->plugin, LOG_UNUSUAL,
+ "Can't find front %s, ignoring in %s",
+ fmt_sciddir_or_pubkey(tmpctx, &p->first_node_id),
+ invrequest_encode(tmpctx, ir->invreq));
+ continue;
+ }
+ assert(first.is_pubkey);
+ /* Self-paths are not fronting nodes */
+ if (!pubkey_eq(&od->id, &first.pubkey))
+ tal_arr_expand(&offer_fronts, first.pubkey);
+ }
- /* Otherwise, use defaults */
- tal_free(offer_fronts);
+ /* None were usable? */
+ if (tal_count(offer_fronts) == 0)
+ return fail_invreq(cmd, ir,
+ "Fronting failed, could not find any fronts");
+ ir->fronting_nodes = offer_fronts;
+ } else {
+ /* Use defaults (if none, add_blindedpaths will choose) */
ir->fronting_nodes = od->fronting_nodes;
}
diff --git a/tests/test_pay.py b/tests/test_pay.py
index b520ed52..ec3b8d49 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -7159,8 +7159,6 @@ def test_offer_currency_no_amount(node_factory):
l1.rpc.decode("lno1qcp4256ypgx9getnwss8vetrw3hhyuckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg")
-# Note: only fails half the time (when it chooses l3!)
-@pytest.mark.xfail
def test_blinded_path_max(node_factory):
"""We have a bug where we can create a invoice_blindedpay with 0 htlc_maximum_msat."""
l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True)
diff --git a/tests/test_xpay.py b/tests/test_xpay.py
index b3cba9cc..a8979acb 100644
--- a/tests/test_xpay.py
+++ b/tests/test_xpay.py
@@ -142,7 +142,6 @@ def test_pay_fakenet(node_factory):
l1.rpc.waitsendpay(payment_hash=hash2, timeout=TIMEOUT, partid=3)
-@pytest.mark.xfail(strict=True)
def test_xpay_simple(node_factory):
l1, l2, l3, l4 = node_factory.get_nodes(4, opts={'may_reconnect': True})
node_factory.join_nodes([l1, l2, l3], wait_for_announce=True)
Why this scored 35/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.