offers: fix fetchinvoice from using disconnected peers.
What changed, and why it matters
This commit fixes a reliability bug in Core Lightning's `fetchinvoice` command. When a peer was disconnected, the node still kept that peer in its internal network map, so it sometimes tried to send an invoice request through a peer that was no longer reachable. The fix removes disconnected peers' channels from the map and also avoids using disabled channels for onion messages. This is a bug fix rather than a security vulnerability: the worst outcome was a failed invoice fetch, not loss of funds or remote code execution.
No urgent security action required. Users should upgrade to the release containing this commit if they rely on `fetchinvoice` / BOLT12 offers, as it improves reliability when peers disconnect. Operators may monitor for any remaining fetchinvoice failures with disconnected peers.
Security signals we found
Fixes a functional/reliability bug in onion-message path selection
Disconnected peers were incorrectly retained in the local routing graph
Disabled channels were not excluded from onion-message path selection
Test re-enabled after removing xfail marker
Evidence from the diff
The patch modifies plugins/establish_onion_path.c so that when building an onion-message path, the plugin no longer considers channels whose half is disabled, and no longer treats disconnected peers as viable next hops. A new helper local_mods_remove_channels() marks channels to a disconnected peer as disabled in the local gossmap modifications, and gossmods_from_listpeers() now receives the base gossmap so it can perform this pruning. A previously expected-to-fail test (test_fetchinvoice_disconnected_reply) is re-enabled by removing its @pytest.mark.xfail(strict=True) marker.
Changed components
plugins/establish_onion_path.ctests/test_pay.pyJSON-RPC command fetchinvoiceInspect captured patch +43 / −5
diff --git a/plugins/establish_onion_path.c b/plugins/establish_onion_path.c
index 2659a839..bfef89ce 100644
--- a/plugins/establish_onion_path.c
+++ b/plugins/establish_onion_path.c
@@ -69,6 +69,10 @@ static bool can_carry_onionmsg(const struct gossmap *map,
{
const struct gossmap_node *n;
+ /* Don't try to use disabled channels! */
+ if (!c->half[dir].enabled)
+ return false;
+
/* Our local additions are always fine, since we checked features then */
if (gossmap_chan_is_localmod(map, c))
return true;
@@ -78,11 +82,40 @@ static bool can_carry_onionmsg(const struct gossmap *map,
return gossmap_node_get_feature(map, n, OPT_ONION_MESSAGES) != -1;
}
+static void local_mods_remove_channels(struct gossmap_localmods *mods,
+ const struct gossmap *gossmap,
+ const struct node_id *selfid,
+ const struct node_id *peerid)
+{
+ const struct gossmap_node *peer = gossmap_find_node(gossmap, peerid);
+ const struct gossmap_node *self = gossmap_find_node(gossmap, selfid);
+ const bool enabled_off = false;
+
+ if (!peer || !self)
+ return;
+
+ for (size_t i = 0; i < self->num_chans; i++) {
+ int dir;
+ struct short_channel_id_dir scidd;
+ struct gossmap_chan *c = gossmap_nth_chan(gossmap, self, i, &dir);
+
+ if (gossmap_nth_node(gossmap, c, !dir) != peer)
+ continue;
+ scidd.scid = gossmap_chan_scid(gossmap, c);
+ scidd.dir = dir;
+
+ /* Set enabled -> false for this channel */
+ gossmap_local_updatechan(mods, &scidd, &enabled_off,
+ NULL, NULL, NULL, NULL, NULL);
+ }
+}
+
/* We add fake channels to gossmap to represent current outgoing connections.
* This allows dijkstra to find transient connections as well. */
static struct gossmap_localmods *
gossmods_from_listpeers(const tal_t *ctx,
- struct command *cmd,
+ struct plugin *plugin,
+ const struct gossmap *gossmap,
const struct node_id *self,
const char *buf,
const jsmntok_t *toks)
@@ -108,12 +141,18 @@ gossmods_from_listpeers(const tal_t *ctx,
JSON_SCAN(json_to_node_id, &peer_id),
JSON_SCAN_TAL(tmpctx, json_tok_bin_from_hex, &features));
if (err) {
- plugin_err(cmd->plugin, "Bad listpeers.peers %zu: %s", i, err);
+ plugin_err(plugin, "Bad listpeers.peers %zu: %s", i, err);
}
- if (!connected || !feature_offered(features, OPT_ONION_MESSAGES))
+ if (!feature_offered(features, OPT_ONION_MESSAGES))
continue;
+ if (!connected) {
+ /* Discard any channels we have with that. */
+ local_mods_remove_channels(mods, gossmap, self, &peer_id);
+ continue;
+ }
+
/* Add a fake channel */
fake_scidd.scid.u64 = i;
fake_scidd.dir = node_id_idx(self, &peer_id);
@@ -145,7 +184,7 @@ static const struct pubkey *path_to_node(const tal_t *ctx,
node_id_from_pubkey(&local_nodeid, local_id);
node_id_from_pubkey(&dst_nodeid, dst_key);
- mods = gossmods_from_listpeers(tmpctx, cmd, &local_nodeid, buf, listpeers);
+ mods = gossmods_from_listpeers(tmpctx, cmd->plugin, gossmap, &local_nodeid, buf, listpeers);
gossmap_apply_localmods(gossmap, mods);
dst = gossmap_find_node(gossmap, &dst_nodeid);
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 899e6189..e8f2e95d 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4796,7 +4796,6 @@ def test_fetchinvoice_autoconnect_if_disconnected(node_factory, bitcoind):
l1.rpc.fetchinvoice(offer)
-@pytest.mark.xfail(strict=True)
def test_fetchinvoice_disconnected_reply(node_factory, bitcoind):
"""We ask for invoice, but reply path doesn't lead directly from recipient"""
l1, l2, l3 = node_factory.get_nodes(3,
Why this scored 31/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.