Repository navigation
Deep reorg deletes node_block rows for blocks at or below the fork point #80
Description
Summary
When a reorg is deep enough that the node's headers reply starts below the fork point, BlockTree.updateHeaders treats the leading, unchanged headers as stale. removeStaleBlocksForNode then deletes node_block rows for blocks that are still on the node's active chain.
Impact
- Blocks at and just below the fork point stay in
blockbut lose theirnode_blockrow for that node, soaccepted_byis[]and everynode_block/accepted_by-filtered query leaves them out (the accepted chain has a hole below the fork). - On ce6d5a0 the rows are lost for good until the agent restarts, because nothing re-accepts heights that the sync state already counts as synced (same mechanism as Reorg leaves gap in accepted chain as new blocks are not downloaded #77). With the Reorg leaves gap in accepted chain as new blocks are not downloaded #77 fix applied (
syncState.blockReorganizationAtHeightinhandleStaleBlocks, as in Upgrades, bugfixes and improvements #76),catchUpViaHeadersre-accepts them but races theDELETE(see Root cause), so they are usually still lost.
Reproduction
Two mock P2P nodes (bitcore-p2p-cash, like src/e2e) that answer getheaders the way BCHN does. Base chain: genesis + 5 blocks. Both nodes follow a 100-block branch A from height 5, then switch to a 101-block branch B from height 5 and announce the new tip via inv.
Real-node sequence: the agent sends a locator for its chain (tip 105: heights 105…96, 95, 94, 92, 88, 80, 64, 32, 0). BCHN's getheaders handler uses FindForkInGlobalIndex, which returns the first locator hash on its active chain (genesis here), and replies starting at the next block. So the reply is heights 1–106, and heights 1–5 are blocks the agent already has. This happens whenever the fork block is not in the locator, i.e. for reorgs deeper than about 11 blocks. Shallow reorgs announced via headers start at fork+1 and are not affected. We reproduced this only with the mock, not with a live BCHN reorg.
Expected vs actual (node_block per node after the reorg)
| Heights | Expected | ce6d5a0 |
|---|---|---|
| 0 | accepted | accepted |
| 1–5 (shared, below fork) | accepted | missing |
| 6–105 (branch B) | accepted | missing (#77) |
| 106 | accepted | accepted |
Agent log: removed 105 stale block(s) from the block tree. / re-organization detected beginning at height: 1, but the fork is at height 5.
Root cause
src/components/block-tree.ts:208-218 (updateHeaders):
const staleHashes = chain.splice(firstHeight, newHashes.length, ...newHashes);
if (staleHashes.length !== 0) this.onStaleBlocks(staleHashes, firstHeight, nodeName);firstHeight is the height after the first header's parent, not the height where the chains diverge. Every overlapping hash comes back as "stale", including ones equal to the new header at that height. handleStaleBlocks (src/agent.ts:1428) passes these hashes to removeStaleBlocksForNode (src/db.ts:549), which deletes them by hash. Once the sync state is rewound (#77 fix), catchUpViaHeaders (src/agent.ts:976) calls acceptBlocksViaHeaders (src/db.ts:496) for the same blocks on another pooled connection. Its INSERT … ON CONFLICT DO NOTHING is a no-op while the old rows still exist, so whenever it runs before the DELETE the rows end up gone. Any headers message that overlaps already-known headers and extends past the tip goes through the same path.
Proposed fix
In updateHeaders, skip the leading headers that match chain, then splice and report stale hashes only from the first height where the hashes differ:
let shared = 0;
while (shared < hashes.length && chain[firstHeight + shared] === hashes[shared]) shared++;
const divergeHeight = firstHeight + shared;
const staleHashes = chain.splice(divergeHeight, hashes.length - shared, ...hashes.slice(shared));
if (staleHashes.length !== 0) this.onStaleBlocks(staleHashes, divergeHeight, nodeName);With this change, the reproduction keeps heights 0–5 on ce6d5a0. On our fork, which also has the #77 fix, the full scenario passed 3 of 3 runs.
Environment
bitauth/chaingraph ce6d5a0 (v1.3.5), Node 24.14, PostgreSQL 18.3, macOS (Apple M-series).
A harness that reproduces this (reorg scenario) is in ParyonUSD#4. We're happy to open a PR with the fix and a block-tree unit test.
Drafted by Claude Opus 5.5 and filed by Claude Fable 5.1 via Claude Code, on behalf of ParyonUSD.
Activity