gossipd: don't forget closed channels until 72 blocks, not 12.
What changed, and why it matters
This commit changes how long Core Lightning nodes remember closed Lightning channels in their network map. The delay is increased from 12 blocks to 72 blocks after the funding output is spent, matching an update to the BOLT protocol specification. This is a protocol-compliance and robustness change, not a fix for an active security vulnerability. The main effect is that nodes keep stale channel information longer, which can slightly delay routing around dead channels but also gives more protection against blockchain reorganizations.
Treat as a routine protocol-compliance update. Review the associated BOLT RFC change (commit 4fe3f0b6056638b46dd3e5317947ebc2491758e8) to confirm the 72-block rationale, run the updated test suite, and deploy as part of normal maintenance. No urgent security response is indicated by the commit itself.
Security signals we found
Protocol constant change (12 -> 72 block delay) driven by BOLT specification update
No input validation, authorization, cryptographic, or memory-safety changes
Test-only modifications adjust block generation counts and expiry expectations
Changelog labels this as a protocol change, not a security fix
Evidence from the diff
The patch updates gossipd/gossmap_manage.c to set the channel-forget deadline to blockheight + 72 instead of +12, aligning with BOLT #7’s updated recommendation. It also bumps the pinned BOLT RFC version in the Makefile and updates numerous test fixtures to generate 73 blocks (spend + 72 confirmations) instead of 13. The change is purely a constant update driven by a spec revision; no memory-safety, cryptographic, or authorization bug is present in the diff.
Changed components
gossipd/gossmap_manage.cMakefile (BOLT RFC pin)tests/test_connection.pytests/test_db.pytests/test_gossip.pytests/test_invoices.pytests/test_pay.pytests/test_plugin.pytests/test_splicing.pytests/test_wallet.pyInspect captured patch +29 / −31
diff --git a/Makefile b/Makefile
index 737bd605..e9a6942e 100644
--- a/Makefile
+++ b/Makefile
@@ -33,7 +33,7 @@ CCANDIR := ccan
# Where we keep the BOLT RFCs
BOLTDIR := ../bolts/
-DEFAULT_BOLTVERSION := 40e21b134e76efaa93eb2027ed0bcc3e2afb1d90
+DEFAULT_BOLTVERSION := 4fe3f0b6056638b46dd3e5317947ebc2491758e8
# Can be overridden on cmdline.
BOLTVERSION := $(DEFAULT_BOLTVERSION)
diff --git a/gossipd/gossmap_manage.c b/gossipd/gossmap_manage.c
index 91a24757..82a20a8b 100644
--- a/gossipd/gossmap_manage.c
+++ b/gossipd/gossmap_manage.c
@@ -1455,12 +1455,12 @@ void gossmap_manage_channel_spent(struct gossmap_manage *gm,
/* BOLT #7:
* - once its funding output has been spent OR reorganized out:
- * - SHOULD forget a channel after a 12-block delay.
+ * - SHOULD forget a channel after a 72-block delay.
*/
- cd.deadline = blockheight + 12;
+ cd.deadline = blockheight + 72;
cd.scid = scid;
- /* Remember locally so we can kill it in 12 blocks */
+ /* Remember locally so we can kill it in 72 blocks */
status_trace("channel %s closing soon due"
" to the funding outpoint being spent",
fmt_short_channel_id(tmpctx, scid));
diff --git a/tests/test_connection.py b/tests/test_connection.py
index f868b31c..c1abc69c 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -4069,7 +4069,7 @@ def test_multichan(node_factory, executor, bitcoind):
l2.rpc.close(l3.info['id'])
l2.rpc.close(scid23b)
- bitcoind.generate_block(13, wait_for_mempool=1)
+ bitcoind.generate_block(73, wait_for_mempool=1)
sync_blockheight(bitcoind, [l1, l2, l3])
# Gossip works as expected.
@@ -4120,7 +4120,7 @@ def test_multichan(node_factory, executor, bitcoind):
"id": 2,
"created_index": 3,
"updated_index": 27,
- "expiry": 135,
+ "expiry": 195,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv3['payment_hash'],
@@ -4129,7 +4129,7 @@ def test_multichan(node_factory, executor, bitcoind):
"id": 3,
"created_index": 4,
"updated_index": 36,
- "expiry": 135,
+ "expiry": 195,
"direction": "out",
"amount_msat": Millisatoshi(100001001),
"payment_hash": inv4['payment_hash'],
diff --git a/tests/test_db.py b/tests/test_db.py
index b186ee35..98b34467 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -96,7 +96,7 @@ def test_block_backfill(node_factory, bitcoind, chainparams):
# Now close the channel and make sure `l3` cleans up correctly:
txid = only_one(l1.rpc.close(l2.info['id'])['txids'])
- bitcoind.generate_block(13, wait_for_mempool=txid)
+ bitcoind.generate_block(73, wait_for_mempool=txid)
wait_for(lambda: len(l3.rpc.listchannels()['channels']) == 0)
diff --git a/tests/test_gossip.py b/tests/test_gossip.py
index 688604da..e55492cd 100644
--- a/tests/test_gossip.py
+++ b/tests/test_gossip.py
@@ -581,8 +581,8 @@ def test_gossip_persistence(node_factory, bitcoind):
l1.rpc.dev_fail(l2.info['id'])
# We need to wait for the unilateral close to hit the mempool,
- # and 12 blocks for nodes to actually forget it.
- bitcoind.generate_block(13, wait_for_mempool=1)
+ # and 72 blocks for nodes to actually forget it.
+ bitcoind.generate_block(73, wait_for_mempool=1)
wait_for(lambda: active(l1) == [scid23, scid23])
wait_for(lambda: active(l2) == [scid23, scid23])
@@ -1445,7 +1445,7 @@ def test_gossip_notices_close(node_factory, bitcoind):
txid = only_one(l2.rpc.close(l3.info['id'])['txids'])
wait_for(lambda: l2.rpc.listpeerchannels(l3.info['id'])['channels'][0]['state'] == 'CLOSINGD_COMPLETE')
- bitcoind.generate_block(13, txid)
+ bitcoind.generate_block(73, txid)
wait_for(lambda: l1.rpc.listchannels()['channels'] == [])
wait_for(lambda: l1.rpc.listnodes()['nodes'] == [])
@@ -1683,7 +1683,7 @@ def test_gossip_store_compact_while_extending(node_factory, bitcoind, executor):
scid23 = only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['short_channel_id']
l2.rpc.close(l3.info['id'])
- bitcoind.generate_block(13, wait_for_mempool=1)
+ bitcoind.generate_block(73, wait_for_mempool=1)
wait_for(lambda: l1.rpc.listchannels(scid23) == {'channels': []})
l1.rpc.setchannel(l2.info['id'], 41, 1004)
@@ -2008,7 +2008,7 @@ def test_topology_leak(node_factory, bitcoind):
# Close and wait for gossip to catchup.
txid = only_one(l2.rpc.close(l3.info['id'])['txids'])
- bitcoind.generate_block(13, txid)
+ bitcoind.generate_block(73, txid)
wait_for(lambda: len(l1.rpc.listchannels()['channels']) == 2)
@@ -2035,7 +2035,7 @@ def test_parms_listforwards(node_factory):
assert len(forwards_dep) == 0
-def test_close_12_block_delay(node_factory, bitcoind):
+def test_close_72_block_delay(node_factory, bitcoind):
l1, l2, l3, l4 = node_factory.line_graph(4, wait_for_announce=True)
# Close l1-l2
@@ -2052,16 +2052,16 @@ def test_close_12_block_delay(node_factory, bitcoind):
# BOLT #7:
# - once its funding output has been spent OR reorganized out:
- # - SHOULD forget a channel after a 12-block delay.
+ # - SHOULD forget a channel after a 72-block delay.
- # That implies 12 blocks *after* spending, i.e. 13 blocks deep!
+ # That implies 72 blocks *after* spending, i.e. 73 blocks deep!
- # 12 blocks deep, l4 still sees it
- bitcoind.generate_block(10)
+ # 72 blocks deep, l4 still sees it
+ bitcoind.generate_block(70)
sync_blockheight(bitcoind, [l4])
assert len(l4.rpc.listchannels(source=l1.info['id'])['channels']) == 1
- # 13 blocks deep does it.
+ # 73 blocks deep does it.
bitcoind.generate_block(1)
wait_for(lambda: l4.rpc.listchannels(source=l1.info['id'])['channels'] == [])
@@ -2490,7 +2490,7 @@ def test_gossmap_lost_node(node_factory, bitcoind):
scid23 = only_one(l2.rpc.listpeerchannels(l3.info['id'])['channels'])['short_channel_id']
l2.rpc.close(l3.info['id'])
- bitcoind.generate_block(13, wait_for_mempool=1)
+ bitcoind.generate_block(73, wait_for_mempool=1)
# Order of nodes is not stable.
sync_blockheight(bitcoind, [l1])
@@ -2522,6 +2522,6 @@ def test_gossip_dying_when_compact(node_factory, bitcoind):
wait_for(lambda: len(l1.rpc.listchannels()["channels"]) == 4)
l1.rpc.call("dev-compact-gossip-store")
- # Now actually close it (12 deep)
- bitcoind.generate_block(11)
+ # Now actually close it (72 deep)
+ bitcoind.generate_block(71)
wait_for(lambda: len(l1.rpc.listchannels()["channels"]) == 2)
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
index 4b67c9aa..161fbd08 100644
--- a/tests/test_invoices.py
+++ b/tests/test_invoices.py
@@ -362,7 +362,7 @@ def test_invoice_routeboost_private(node_factory, bitcoind):
# It will use an explicit exposeprivatechannels even if it thinks its a dead-end
l0.rpc.close(l1.info['id'])
l0.wait_for_channel_onchain(l1.info['id'])
- bitcoind.generate_block(13)
+ bitcoind.generate_block(73)
wait_for(lambda: l2.rpc.listchannels(scid_dummy)['channels'] == [])
inv = l2.rpc.invoice(amount_msat=123456, label="inv7", description="?", exposeprivatechannels=scid)
diff --git a/tests/test_pay.py b/tests/test_pay.py
index cc22f26b..2d0c30f8 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -5891,7 +5891,7 @@ def test_offer_paths(node_factory, bitcoind):
# Make scid path invalid by closing it
close = l1.rpc.close(paths[0]['first_scid'])
- bitcoind.generate_block(13, wait_for_mempool=only_one(close['txids']))
+ bitcoind.generate_block(73, wait_for_mempool=only_one(close['txids']))
wait_for(lambda: l5.rpc.listchannels(paths[0]['first_scid']) == {'channels': []})
# Now connect l5->l4, and it will be able to reach l3 via that, and join blinded path.
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 10b93255..2a40d475 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -4320,7 +4320,7 @@ def test_sql(node_factory, bitcoind):
# This has to wait for the hold_invoice plugin to let go!
open(os.path.join(l3.daemon.lightning_dir, TEST_NETWORK, "unhold"), "w").close()
txid = only_one(l1.rpc.close(l2.info['id'])['txids'])
- bitcoind.generate_block(13, wait_for_mempool=txid)
+ bitcoind.generate_block(73, wait_for_mempool=txid)
wait_for(lambda: len(l3.rpc.listchannels(source=l1.info['id'])['channels']) == 0)
assert len(l3.rpc.sql("SELECT * FROM channels WHERE source = X'{}';".format(l1.info['id']))['rows']) == 0
l3.daemon.wait_for_log("Deleting channel: {}".format(scid))
diff --git a/tests/test_splicing.py b/tests/test_splicing.py
index 458f432f..f713ff7b 100644
--- a/tests/test_splicing.py
+++ b/tests/test_splicing.py
@@ -229,7 +229,7 @@ def test_splice_gossip(node_factory, bitcoind):
bitcoind.generate_block(5, wait_for_mempool=result['txid'])
- # l3 will see channel dying, but still consider it OK for 12 blocks.
+ # l3 will see channel dying, but still consider it OK for 72 blocks.
l3.daemon.wait_for_log(f'gossipd: channel {pre_splice_scid} closing soon due to the funding outpoint being spent')
assert len(l3.rpc.listchannels(short_channel_id=pre_splice_scid)['channels']) == 2
assert len(l3.rpc.listchannels(source=l1.info['id'])['channels']) == 1
@@ -246,7 +246,7 @@ def test_splice_gossip(node_factory, bitcoind):
wait_for(lambda: len(l3.rpc.listchannels(short_channel_id=post_splice_scid)['channels']) == 2)
assert len(l3.rpc.listchannels(short_channel_id=pre_splice_scid)['channels']) == 2
- bitcoind.generate_block(7)
+ bitcoind.generate_block(67)
# The old channel should fall off l3's perspective
wait_for(lambda: l3.rpc.listchannels(short_channel_id=pre_splice_scid)['channels'] == [])
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index 5be686d1..e4d1cc4f 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -2562,7 +2562,7 @@ def test_unspend_during_reorg(node_factory, bitcoind):
# Now, l3 sees the close, marks channel dying.
l1.rpc.close(l2.info['id'])
spentheight = bitcoind.rpc.getblockcount() + 1
- bitcoind.generate_block(14, wait_for_mempool=1)
+ bitcoind.generate_block(74, wait_for_mempool=1)
wait_for(lambda: len(l3.rpc.listchannels()['channels']) == 2)
# In one fell swoop it goes through dying, to dead (12 blocks)
@@ -2576,12 +2576,10 @@ def test_unspend_during_reorg(node_factory, bitcoind):
# Restart, see replay.
l3.stop()
# This is enough to take channel from dying to dead.
- bitcoind.generate_block(10)
+ bitcoind.generate_block(70)
l3.start()
# Channel should still be dead.
- l3.daemon.wait_for_log(f"Adding block {spentheight}")
-
sync_blockheight(bitcoind, [l3])
assert only_one(l3.db_query(f"SELECT spendheight as spendheight FROM utxoset WHERE blockheight={blockheight} AND txindex={txindex}"))['spendheight'] == spentheight
Why this scored 37/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.