[Medium] Resumed snapshots mix blocks and publish divergent Merkle roots

Severity: Medium

Component: ghost-preclaims @ 1538567b4651f9daddef534192723ff671693d35 (finder.py lines 15, 50 to 61 and 79 to 110)

Source review only. No live network calls, no transactions. I reproduced the resume logic offline, 5 of 5 runs identical.

Description

Each snapshot record is only meaningful at one block, but records carry no block field and the default for the block argument is the string latest, which the node re resolves on every call. A run that is interrupted and resumed later appends records read at a different block height to records from the first run. Nothing records or compares the block per record, and resume does not require the same block argument, so the resulting snapshot, and the Merkle root built from it, corresponds to no consistent chain state.

target_block = int(args.block) if args.block.isdigit() else args.block
...
output_file = os.path.join("snapshots", f"{chain_id}.json")
snapshot, start_token_id = load_existing_snapshot(output_file)

Proof of concept (a runnable script that builds all three snapshot sets and hashes them with the repository’s own forester.generate_tree; no network. 5 of 5 runs byte identical):

# --- finder.py: the loop reads ownerOf/collateralBalances per call with
# block_identifier resolved by the node each time; records carry no block field.
# Run 1 at block 100 collects token 1 (owner A) and is interrupted.
# Run 2 resumed without --block resolves "latest" = block 200 and collects
# token 2 (owner D, who bought it after block 100); token 1 is never re-read.
# forester.generate_tree then hashes the mixed list as if it were one state.
import forester  # the repository's own tree builder
def root_of(snap):
    tree, _ = forester.generate_tree(snap, 11155111)
    return tree[-1].hex()
mixed     = run1_rows + run2_rows      # {A@block100, D@block200}
single100 = full_scan(block=100)       # {A, B}
single200 = full_scan(block=200)       # {C, D}

Raw output of the PoC (identical on all 5 runs):

root(block100 only) = 611cbab3e51f05cb0bf74c70ffd9507adeb8ba4d4a9db3eff83b8edc18b273b9
root(block200 only) = 8d9dfc504173881f577df0e8187f5ae2f05f84e3c81562af74b3d84bfe9183a7
root(MIXED resume)  = 83aa751bd481f8cf6cbb8a0e1d6d6ac4418a307ee2f1e97f25c447cacf65808d
MIXED_MATCHES_EITHER_SINGLE_BLOCK_ROOT = False   (False = divergent)
ROOT100_NE_ROOT200  = True

The mixed record set matches neither single-block scan, and its root equals neither single-block root, so the resumed file publishes a root that corresponds to no consistent chain state. The bonus argument is also unpinned, so a resume with a different bonus mixes share units inside one root.

Steps to reproduce

  1. Clone and pin::
git clone https://git.ghostchain.io/ghostchain/ghost-preclaims.git && git -C ghost-preclaims checkout 1538567b4651f9daddef534192723ff671693d35
  1. Save the PoC above as poc_mixed_blocks.py (it imports the repository’s own forester.generate_tree from your clone).
  2. Run:
python3 poc_mixed_blocks.py
  1. Observe three different roots: the block 100 scan, the block 200 scan, and the mixed resume set, and that the mixed root equals neither single-block root.
  2. Optional live confirmation of the precondition: start finder.py with an RPC URL on a small collection, interrupt it, then re run it later without a block argument. The second run appends rows read at a different head block, and the saved file has no block field per record to detect it.
  3. Run the PoC 5 times; the output is byte identical each run.

Impact

Two operators running interrupted jobs at different times get different valid looking roots for the same network, and a mixed root can pay a seller for the old block and the buyer for the new block in the same tree. The on chain claim root becomes unreproducible and undisputable, which is exactly the non deterministic state class this program flags for this repository.

Fix

Store the block number and chain ID on every record, require resume to pass the identical block value, refuse to append to an existing snapshot while running with latest, and pin the bonus per snapshot file.

Thank you for your report!

This Python script is meant to be used by anyone to verify that the preclaims are correctly reflected in the genesis file, so many thanks for your attention to it!

Strictly speaking, this isn’t a bug - it’s more a question of how to make the script easy and predictable to run for anyone. If you have any ideas, I’d be glad to hear them.