From 761d1d2b157c049387677830e735e5ea699819b9 Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 3 Aug 2026 02:07:47 -0700 Subject: [PATCH] fix(utxo): ReadUtxo + DisconnectBlock reconstruct UTXOs from txindex.vSpent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadUtxo (src/txdb-base.cpp) lacked the lazy-fallback path that HaveUtxo already had. When the UTXO snapshot is incomplete (as Sami reported) or the chain DB was migrated incompletely, ReadUtxo returns false even though the output is actually unspent on chain — blocks spending those outputs get rejected with 'input not found', and the chain stalls. GLM-5.2 and DeepSeek-V4-Pro independently identified this as the primary sync staller when auditing the chain freeze at block 2,224,763. Fix: when UTXO DB doesn't have the entry but txindex.vSpent[n] is null (output was never spent), read the transaction from disk and reconstruct the full CUtxoEntry (value, script, flags, tx time) plus the exact block height via mapBlockIndex lookup. DisconnectBlock (src/main.cpp) had the same nHeight=0 approximation in the restore-input path; applied the same height-reconstruction pattern for consistency. Validation safety: every block 0 to 2,224,763 that successfully connected on the live chain did so via the UTXO DB entry written by ConnectBlock at the time. This fallback only activates when the UTXO DB entry is MISSING, which cannot happen for any block that ever validated. Zero historical block validation changes. --- src/main.cpp | 15 +++++++++++- src/txdb-base.cpp | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 806c572..89d6f75 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2174,11 +2174,24 @@ bool CBlock::DisconnectBlock(CTxDBBase& txdb, CBlockIndex* pindex) const CTxOut& prevout = txPrev.vout[txin.prevout.n]; CUtxoEntry utxo; utxo.nValue = prevout.nValue; - utxo.nHeight = 0; // approximation; exact height not critical for restored UTXOs utxo.scriptPubKey = prevout.scriptPubKey; utxo.fCoinBase = txPrev.IsCoinBase(); utxo.fCoinStake = txPrev.IsCoinStake(); utxo.nTxTime = txPrev.nTime; + + // Reconstruct exact height via block index lookup. + // Falls back to 0 if mapBlockIndex doesn't have the + // tx's block yet (safe — ConnectInputs maturity + // check then requires COINBASE_MATURITY confirmations). + utxo.nHeight = 0; + CBlock blockHeader; + if (blockHeader.ReadFromDisk(txindex.pos.nFile, txindex.pos.nBlockPos, false)) + { + auto bmi = mapBlockIndex.find(blockHeader.GetHash()); + if (bmi != mapBlockIndex.end()) + utxo.nHeight = bmi->second->nHeight; + } + txdb.WriteUtxo(txin.prevout.hash, txin.prevout.n, utxo); } } diff --git a/src/txdb-base.cpp b/src/txdb-base.cpp index cb2dfe3..222a660 100644 --- a/src/txdb-base.cpp +++ b/src/txdb-base.cpp @@ -379,6 +379,66 @@ bool CTxDBBase::ReadUtxo(const uint256& hash, unsigned int n, CUtxoEntry& entry) } bool fFound = Read(make_pair(string("u"), make_pair(hash, n)), entry); + if (fFound) + { + { + LOCK(g_cs_utxoCache); + if (g_mapUtxoCache.size() < UTXO_CACHE_MAX_ENTRIES) + PutUtxoCacheEntry(outpoint, entry, fFound); + } + return true; + } + + // Lazy fallback: same as HaveUtxo — check old CTxIndex vSpent. If the + // snapshot/UTXO DB doesn't have this output but the txindex says it was + // never spent, reconstruct the CUtxoEntry by reading the transaction + // from disk. This is the primary sync-recovery path for snapshot-loaded + // nodes whose UTXO set is incomplete (the chain has been frozen at + // 2,224,763 since 2026-07-18 because some pre-snapshot UTXOs were + // missing from the snapshot and FetchInputs' ReadUtxo could not find + // them, so blocks spending them were rejected). + // + // Validation safety: every block that ever validated pre-freeze did so + // via the UTXO DB entry written by ConnectBlock at the time. This + // fallback only activates when the UTXO DB entry is MISSING, which + // cannot happen for any block that successfully connected on the live + // chain. So this fallback does NOT change consensus validation of any + // block (0 to 2,224,763) — it only provides a recovery path for nodes + // whose UTXO set was reconstructed incompletely. + CTxIndex txindex; + if (ReadTxIndex(hash, txindex) + && n < txindex.vSpent.size() + && txindex.vSpent[n].IsNull()) + { + CTransaction txPrev; + if (txPrev.ReadFromDisk(txindex.pos)) + { + if (n < txPrev.vout.size()) + { + const CTxOut& txout = txPrev.vout[n]; + entry.nValue = txout.nValue; + entry.scriptPubKey = txout.scriptPubKey; + entry.fCoinBase = txPrev.IsCoinBase(); + entry.fCoinStake = txPrev.IsCoinStake(); + entry.nTxTime = txPrev.nTime; + + // Reconstruct exact block height: read the block header + // at the txindex's disk position, hash it, look up in + // mapBlockIndex for the canonical height. Same pattern + // as the FetchInputs backfill (src/main.cpp ~line 1873). + entry.nHeight = 0; + CBlock blockHeader; + if (blockHeader.ReadFromDisk(txindex.pos.nFile, txindex.pos.nBlockPos, false)) + { + auto bmi = mapBlockIndex.find(blockHeader.GetHash()); + if (bmi != mapBlockIndex.end()) + entry.nHeight = bmi->second->nHeight; + } + + fFound = true; + } + } + } { LOCK(g_cs_utxoCache);