gossipd: don't shortcut dying phase for local channels.
What changed, and why it matters
This change adjusts how Core Lightning handles its own closing payment channels on the network map. Previously, the node immediately erased its own spent channels from the gossip map, while other nodes kept them in a temporary 'dying' state. Now local channels follow the same dying-phase rules as everyone else's, and the routing plugin is told to ignore those local dying channels when finding payment routes. The main visible effect is that the network graph briefly shows two channel entries during a splice or close, instead of one. The commit does not describe this as a security fix, but the old shortcut could have caused routing confusion or inconsistent gossip handling.
Treat as a correctness/robustness improvement rather than an urgent security patch. Review whether the old immediate-deletion behavior could have caused routing loops, failed payments, or inconsistent channel state during splices or closes. No immediate emergency action is indicated by the commit alone.
Security signals we found
Behavior change in gossip state machine for local channels
Routing plugin now explicitly excludes local dying channels
Removal of asymmetric local-channel shortcut
Test expectations updated to reflect delayed deletion
Evidence from the diff
In gossipd/gossmap_manage.c, the special-case branch that immediately deleted a local channel when its funding outpoint was spent is removed. Local channels now enter the standard dying phase like remote channels. In plugins/askrene/askrene.c, do_getroutes() is updated to detect local channels marked as dying and disable both directions via gossmap_local_updatechan() so the router will not try to use them. Test log needles are updated from ‘Deleting channel … due to the funding outpoint being spent’ to ‘closing soon due to the funding outpoint being spent’.
Changed components
gossipd/gossmap_manage.cplugins/askrene/askrene.ctests/test_closing.pytests/test_misc.pyInspect captured patch +29 / −13
diff --git a/gossipd/gossmap_manage.c b/gossipd/gossmap_manage.c
index 7a371d0f..c0589148 100644
--- a/gossipd/gossmap_manage.c
+++ b/gossipd/gossmap_manage.c
@@ -1332,7 +1332,6 @@ void gossmap_manage_channel_spent(struct gossmap_manage *gm,
struct short_channel_id scid)
{
struct gossmap_chan *chan;
- const struct gossmap_node *me;
const u8 *msg;
struct chan_dying cd;
struct gossmap *gossmap = gossmap_manage_get_gossmap(gm);
@@ -1341,14 +1340,6 @@ void gossmap_manage_channel_spent(struct gossmap_manage *gm,
if (!chan)
return;
- me = gossmap_find_node(gossmap, &gm->daemon->id);
- /* We delete our own channels immediately, since we have local knowledge */
- if (gossmap_nth_node(gossmap, chan, 0) == me
- || gossmap_nth_node(gossmap, chan, 1) == me) {
- kill_spent_channel(gm, gossmap, scid);
- return;
- }
-
/* Is it already dying? It's lightningd re-telling us */
if (channel_already_dying(gm->dying_channels, scid))
return;
diff --git a/plugins/askrene/askrene.c b/plugins/askrene/askrene.c
index 23e541d7..fe8f477f 100644
--- a/plugins/askrene/askrene.c
+++ b/plugins/askrene/askrene.c
@@ -573,6 +573,7 @@ static struct command_result *do_getroutes(struct command *cmd,
struct route **routes;
struct flow **flows;
struct json_stream *response;
+ const struct gossmap_node *me;
/* update the gossmap */
if (gossmap_refresh(askrene->gossmap)) {
@@ -593,6 +594,30 @@ static struct command_result *do_getroutes(struct command *cmd,
rq->additional_costs = info->additional_costs;
rq->maxparts = info->maxparts;
+ /* We also eliminate any local channels we *know* are dying.
+ * Most channels get 12 blocks grace in case it's a splice,
+ * but if it's us, we know about the splice already. */
+ me = gossmap_find_node(rq->gossmap, &askrene->my_id);
+ if (me) {
+ for (size_t i = 0; i < me->num_chans; i++) {
+ struct short_channel_id_dir scidd;
+ const struct gossmap_chan *c = gossmap_nth_chan(rq->gossmap,
+ me, i, NULL);
+ if (!gossmap_chan_is_dying(rq->gossmap, c))
+ continue;
+
+ scidd.scid = gossmap_chan_scid(rq->gossmap, c);
+ /* Disable both directions */
+ for (scidd.dir = 0; scidd.dir < 2; scidd.dir++) {
+ bool enabled = false;
+ gossmap_local_updatechan(localmods,
+ &scidd,
+ &enabled,
+ NULL, NULL, NULL, NULL, NULL);
+ }
+ }
+ }
+
/* apply selected layers to the localmods */
apply_layers(askrene, rq, &info->source, info->amount, localmods,
info->layers, info->local_layer);
diff --git a/tests/test_closing.py b/tests/test_closing.py
index f3c7dbee..33abde9c 100644
--- a/tests/test_closing.py
+++ b/tests/test_closing.py
@@ -1851,8 +1851,8 @@ def test_onchaind_replay(node_factory, bitcoind):
# Wait for nodes to notice the failure, this seach needle is after the
# DB commit so we're sure the tx entries in onchaindtxs have been added
- l1.daemon.wait_for_log("Deleting channel .* due to the funding outpoint being spent")
- l2.daemon.wait_for_log("Deleting channel .* due to the funding outpoint being spent")
+ l1.daemon.wait_for_log("closing soon due to the funding outpoint being spent")
+ l2.daemon.wait_for_log("closing soon due to the funding outpoint being spent")
# We should at least have the init tx now
assert len(l1.db_query("SELECT * FROM channeltxs;")) > 0
diff --git a/tests/test_misc.py b/tests/test_misc.py
index e159b1c4..c7a69739 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1488,8 +1488,8 @@ def test_funding_reorg_remote_lags(node_factory, bitcoind):
l1.rpc.close(l2.info['id'])
bitcoind.generate_block(1, True)
- l1.daemon.wait_for_log(r'Deleting channel')
- l2.daemon.wait_for_log(r'Deleting channel')
+ l1.daemon.wait_for_log(r'closing soon due to the funding outpoint being spent')
+ l2.daemon.wait_for_log(r'closing soon due to the funding outpoint being spent')
@pytest.mark.openchannel('v1')
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.