lightningd: scan back to seek missing UTXOs.
What changed, and why it matters
This commit adds a one-time background scan that re-examines old blocks to find UTXOs (unspent transaction outputs) the wallet may have missed earlier. It is a data-recovery / consistency fix, not an obvious exploit patch. The commit message does not call it a security fix, and there are no supplied references linking it to an attack. The change could matter for security because missed UTXOs can affect channel balances and on-chain funds, but the diff itself is defensive housekeeping.
Treat as a routine reliability fix. Review whether missed UTXOs could have led to incorrect channel state or unclaimed funds, and consider whether additional database validation is needed. No immediate exploit mitigation is evident from the diff alone.
Security signals we found
Data consistency / recovery scan for missed UTXOs
Refactoring of spend-tracking logic to support historical replay
Database state variable to ensure one-time scan behavior
No explicit security framing by author or vendor
Evidence from the diff
The patch introduces old_block_scan state and a fixup_scan() routine in lightningd/chaintopology.c. On startup, if the node has existing blocks, it records the earliest known block height and then walks forward from that height, re-running topo_update_spends() against historical blocks to catch any wallet outpoints that were not previously marked as spent. topo_update_spends() is refactored to accept a transaction array, txid array, and block height instead of a struct block, so it can be reused for both live tip updates and the historical scan. A new wallet_blocks_minheight() helper is added to find the oldest processed block. The scan is persisted via fixup_block_scan db intvar and runs only once.
Changed components
lightningd/chaintopology.clightningd/chaintopology.hwallet/wallet.cwallet/wallet.hInspect captured patch +84 / −10
diff --git a/lightningd/chaintopology.c b/lightningd/chaintopology.c
index 9d85b8ce..f977dc45 100644
--- a/lightningd/chaintopology.c
+++ b/lightningd/chaintopology.c
@@ -888,8 +888,8 @@ static void updates_complete(struct chain_topology *topo)
}
static void record_wallet_spend(struct lightningd *ld,
- struct bitcoin_outpoint *outpoint,
- struct bitcoin_txid *txid,
+ const struct bitcoin_outpoint *outpoint,
+ const struct bitcoin_txid *txid,
u32 tx_blockheight)
{
struct utxo *utxo;
@@ -911,12 +911,15 @@ static void record_wallet_spend(struct lightningd *ld,
/**
* topo_update_spends -- Tell the wallet about all spent outpoints
*/
-static void topo_update_spends(struct chain_topology *topo, struct block *b)
+static void topo_update_spends(struct chain_topology *topo,
+ struct bitcoin_tx **txs,
+ const struct bitcoin_txid *txids,
+ u32 blockheight)
{
const struct short_channel_id *spent_scids;
- const size_t num_txs = tal_count(b->full_txs);
+ const size_t num_txs = tal_count(txs);
for (size_t i = 0; i < num_txs; i++) {
- const struct bitcoin_tx *tx = b->full_txs[i];
+ const struct bitcoin_tx *tx = txs[i];
for (size_t j = 0; j < tx->wtx->num_inputs; j++) {
struct bitcoin_outpoint outpoint;
@@ -924,9 +927,9 @@ static void topo_update_spends(struct chain_topology *topo, struct block *b)
bitcoin_tx_input_get_outpoint(tx, j, &outpoint);
if (wallet_outpoint_spend(tmpctx, topo->ld->wallet,
- b->height, &outpoint))
+ blockheight, &outpoint))
record_wallet_spend(topo->ld, &outpoint,
- &b->txids[i], b->height);
+ &txids[i], blockheight);
}
}
@@ -934,8 +937,8 @@ static void topo_update_spends(struct chain_topology *topo, struct block *b)
/* Retrieve all potential channel closes from the UTXO set and
* tell gossipd about them. */
spent_scids =
- wallet_utxoset_get_spent(tmpctx, topo->ld->wallet, b->height);
- gossipd_notify_spends(topo->bitcoind->ld, b->height, spent_scids);
+ wallet_utxoset_get_spent(tmpctx, topo->ld->wallet, blockheight);
+ gossipd_notify_spends(topo->bitcoind->ld, blockheight, spent_scids);
}
static void topo_add_utxos(struct chain_topology *topo, struct block *b)
@@ -982,7 +985,7 @@ static void add_tip(struct chain_topology *topo, struct block *b)
trace_span_end(b);
trace_span_start("topo_update_spends", b);
- topo_update_spends(topo, b);
+ topo_update_spends(topo, b->full_txs, b->txids, b->height);
trace_span_end(b);
/* Only keep the transactions we care about. */
@@ -1388,6 +1391,7 @@ void setup_topology(struct chain_topology *topo)
struct bitcoin_block *blk;
bool blockscan_start_set;
u32 blockscan_start;
+ s64 fixup;
/* This waits for bitcoind. */
bitcoind_check_commands(topo->bitcoind);
@@ -1413,6 +1417,15 @@ void setup_topology(struct chain_topology *topo)
blockscan_start = blocknum_reduce(blockscan_start, topo->ld->config.rescan);
}
+ 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);
+ db_set_intvar(topo->ld->wallet->db, "fixup_block_scan",
+ topo->old_block_scan);
+ } else {
+ topo->old_block_scan = fixup;
+ }
db_commit_transaction(topo->ld->wallet->db);
/* Sanity checks, then topology initialization. */
@@ -1509,6 +1522,36 @@ void setup_topology(struct chain_topology *topo)
tal_add_destructor(topo, destroy_chain_topology);
}
+static void fixup_scan_block(struct bitcoind *bitcoind,
+ u32 height,
+ struct bitcoin_blkid *blkid,
+ struct bitcoin_block *blk,
+ struct chain_topology *topo)
+{
+ log_debug(topo->ld->log, "fixup_scan: block %u with %zu txs", height, tal_count(blk->tx));
+ topo_update_spends(topo, blk->tx, blk->txids, height);
+
+ /* Caught up. */
+ if (height == get_block_height(topo)) {
+ log_info(topo->ld->log, "Scanning for missed UTXOs finished");
+ db_set_intvar(topo->ld->wallet->db, "fixup_block_scan", 0);
+ return;
+ }
+
+ db_set_intvar(topo->ld->wallet->db, "fixup_block_scan", ++topo->old_block_scan);
+ bitcoind_getrawblockbyheight(topo, topo->bitcoind,
+ topo->old_block_scan,
+ fixup_scan_block, topo);
+}
+
+static void fixup_scan(struct chain_topology *topo)
+{
+ log_info(topo->ld->log, "Scanning for missed UTXOs from block %u", topo->old_block_scan);
+ bitcoind_getrawblockbyheight(topo, topo->bitcoind,
+ topo->old_block_scan,
+ fixup_scan_block, topo);
+}
+
void begin_topology(struct chain_topology *topo)
{
/* If we were not synced, start looping to check */
@@ -1518,6 +1561,9 @@ void begin_topology(struct chain_topology *topo)
start_fee_estimate(topo);
/* Regular block updates */
try_extend_tip(topo);
+
+ if (topo->old_block_scan)
+ fixup_scan(topo);
}
void stop_topology(struct chain_topology *topo)
diff --git a/lightningd/chaintopology.h b/lightningd/chaintopology.h
index 99d614b5..8515cb5b 100644
--- a/lightningd/chaintopology.h
+++ b/lightningd/chaintopology.h
@@ -143,6 +143,9 @@ struct chain_topology {
/* The number of headers known to the bitcoin backend at startup. Not
* updated after the initial check. */
u32 headercount;
+
+ /* Progress on routine to look for old missed transactions. 0 = not interested. */
+ u32 old_block_scan;
};
/* Information relevant to locating a TX in a blockchain. */
diff --git a/wallet/wallet.c b/wallet/wallet.c
index 28085444..f67be5ec 100644
--- a/wallet/wallet.c
+++ b/wallet/wallet.c
@@ -2554,6 +2554,22 @@ u32 wallet_blocks_maxheight(struct wallet *w)
return max;
}
+u32 wallet_blocks_minheight(struct wallet *w)
+{
+ u32 min = 0;
+ struct db_stmt *stmt = db_prepare_v2(w->db, SQL("SELECT MIN(height) FROM blocks;"));
+ db_query_prepared(stmt);
+
+ /* If we ever processed a block we'll get the latest block in the chain */
+ if (db_step(stmt)) {
+ if (!db_col_is_null(stmt, "MIN(height)")) {
+ min = db_col_int(stmt, "MIN(height)");
+ }
+ }
+ tal_free(stmt);
+ return min;
+}
+
static void wallet_channel_config_insert(struct wallet *w,
struct channel_config *cc)
{
diff --git a/wallet/wallet.h b/wallet/wallet.h
index 1f1b81da..e367037d 100644
--- a/wallet/wallet.h
+++ b/wallet/wallet.h
@@ -789,6 +789,15 @@ 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.
+ *
+ * Will return the 0 if the wallet was never used before.
+ *
+ * @w: wallet to load from.
+ */
+u32 wallet_blocks_minheight(struct wallet *w);
+
/**
* wallet_extract_owned_outputs - given a tx, extract all of our outputs
*/
Why this scored 44/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.