Skip to content

Deep reorg deletes node_block rows for blocks at or below the fork point #80

Open
ParyonUSD/chaingraph
#7

Description

@rnbrady

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions