lightningd: fix db constraint error when fixing up old blocks.
What changed, and why it matters
This commit fixes a crash in Core Lightning (a Bitcoin Lightning Network node implementation) that could occur during a one-time database repair scan. The node would start scanning from the oldest block it had ever stored, including blocks saved only for old network gossip queries. If one of those early blocks contained a spend of the node's own funds, the code tried to update a database record referencing a block height that did not exist in the main chain-tracking table, triggering a foreign-key constraint failure and crashing the daemon with SIGABRT. The fix makes the scan start from the oldest contiguous block in the main chain record instead, skipping the gossip-only backfilled blocks.
Upgrade to a Core Lightning release containing this commit. The issue is a denial-of-service (node crash) during normal startup repair, not a funds-loss bug, but it can make a node unavailable. Operators seeing the FOREIGN KEY crash on startup should upgrade rather than attempt manual database edits.
Security signals we found
Daemon abort/crash (SIGABRT via fatal_vfmt/db_error)
SQL FOREIGN KEY constraint failure in wallet_outpoint_spend
Crash triggered by on-chain spend of wallet-owned UTXO during startup repair scan
Fixup scan boundary derived from sparse block table including gossip backfill
Availability impact: node terminates unexpectedly
Evidence from the diff
The crash occurs in wallet_outpoint_spend() when updating outputs.spend_height and outputs.status. The UPDATE has a FOREIGN KEY on spend_height referencing blocks.height. During the ‘fixup_block_scan’ startup repair, the node scans from wallet_blocks_minheight(), which returned the absolute minimum height in the blocks table. That table can contain sparse, backfilled blocks from old gossip queries, so the minimum may be below the contiguous chain history. If a UTXO the wallet is watching is spent in one of those sparse blocks, topo_update_spends() calls wallet_outpoint_spend() with that block’s height, violating the foreign key and causing db_fatal() to abort. The patch renames wallet_blocks_minheight() to wallet_blocks_contig_minheight() and changes its SQL to return the first height of the latest contiguous run of blocks (the lowest height whose predecessor is absent), so the fixup scan only covers the chain the wallet actually tracked.
Changed components
lightningd/chaintopology.cwallet/wallet.cwallet/wallet.hdb/utils.c (fatal path)outputs table foreign key constraintInspect captured patch +15 / −8
diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c
index 9cb12379..6ab4c659 100644
--- a/lightningd/chaintopology.c
+++ b/lightningd/chaintopology.c
@@ -1420,7 +1420,7 @@ void setup_topology(struct chain_topology *topo)
fixup = db_get_intvar(topo->ld->wallet->db, "fixup_block_scan", -1);
if (fixup == -1) {
/* Never done fixup: this is set to non-zero if we have blocks. */
- topo->old_block_scan = wallet_blocks_minheight(topo->ld->wallet);
+ topo->old_block_scan = wallet_blocks_contig_minheight(topo->ld->wallet);
db_set_intvar(topo->ld->wallet->db, "fixup_block_scan",
topo->old_block_scan);
} else {
diff --git a/wallet/wallet.c b/wallet/wallet.c
index f67be5ec..a8f4da06 100644
--- a/wallet/wallet.c
+++ b/wallet/wallet.c
@@ -2554,16 +2554,22 @@ u32 wallet_blocks_maxheight(struct wallet *w)
return max;
}
-u32 wallet_blocks_minheight(struct wallet *w)
+u32 wallet_blocks_contig_minheight(struct wallet *w)
{
u32 min = 0;
- struct db_stmt *stmt = db_prepare_v2(w->db, SQL("SELECT MIN(height) FROM blocks;"));
+ struct db_stmt *stmt = db_prepare_v2(w->db, SQL("SELECT MAX(b.height)"
+ " FROM blocks b"
+ " WHERE NOT EXISTS ("
+ " SELECT 1"
+ " FROM blocks b2"
+ " WHERE b2.height = b.height - 1)"));
db_query_prepared(stmt);
- /* If we ever processed a block we'll get the latest block in the chain */
+ /* If we ever processed a block we'll get the first block in
+ * the last run of blocks */
if (db_step(stmt)) {
- if (!db_col_is_null(stmt, "MIN(height)")) {
- min = db_col_int(stmt, "MIN(height)");
+ if (!db_col_is_null(stmt, "MAX(b.height)")) {
+ min = db_col_int(stmt, "MAX(b.height)");
}
}
tal_free(stmt);
diff --git a/wallet/wallet.h b/wallet/wallet.h
index e367037d..dcfc3878 100644
--- a/wallet/wallet.h
+++ b/wallet/wallet.h
@@ -790,13 +790,14 @@ void wallet_channel_stats_incr_out_fulfilled(struct wallet *w, u64 cdbid, struct
u32 wallet_blocks_maxheight(struct wallet *w);
/**
- * Retrieve the blockheight of the first block processed by lightningd.
+ * Retrieve the blockheight of the first block processed by lightningd (ignoring
+ * backfilled blocks for gossip).
*
* Will return the 0 if the wallet was never used before.
*
* @w: wallet to load from.
*/
-u32 wallet_blocks_minheight(struct wallet *w);
+u32 wallet_blocks_contig_minheight(struct wallet *w);
/**
* wallet_extract_owned_outputs - given a tx, extract all of our outputs
Why this scored 43/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.