feat: add superbank-verify PoH validation binary#54
Conversation
New workspace crate crates/superbank-verify that validates Solana Proof-of-History from the ClickHouse tables superbank already populates (blocks_metadata, entries, transactions), for genesis-to-tip or arbitrary slot/epoch ranges. - verify core ported from Agave solana-entry (next_hash + merkle signature mixin), differentially tested against solana-entry - structural mode replicates blockstore_processor::verify_ticks invariants (tick counts incl. skipped slots, trailing tick, verify_tick_hash_count, tx-index tiling) without hash recomputation - full mode recomputes every SHA-256 hash, block-parallel via rayon - chain walk checks blockhash linkage, classifies skipped vs missing slots, supports external anchors and genesis-hash pinning - built-in mainnet hashes_per_tick era table (update_hashes_per_tick2..6 activation slots verified against mainnet feature accounts) - windowed ClickHouse reads with LIMIT 1 BY dedup + duplicate-conflict probes; checkpoint file + --resume for multi-day runs; JSONL findings report; prometheus metrics on :9902; documented exit-code contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMroHdSWEeRLPzD7FysmUz
Mctursh
left a comment
There was a problem hiding this comment.
Went through this carefully. The verifier core is solid: I traced next_hash and the signature merkle mixin against agave solana-entry and they match (the differential tests actually run the real agave functions), structural.rs matches verify_ticks, the chain walk's skipped-vs-missing logic is right, and the eras table checks out. I chased the activated_at boundary into bank.rs since it sits a few slots past the epoch start for tick2/3/4, and it's correct because activated_at is stamped in process_new_epoch. Nice call handling warmup in epoch.rs instead of the getEpochSchedule path.
Everything I flagged inline is operational, not the verifier. A few small ones not worth their own threads: findings JSONL is at-least-once across a crash between the flush and the checkpoint save (exit code's fine, it's from the counters); duplicate --anchor slots collapse silently in the HashMap; and an EntryIndexGap disables hashing for the whole block, not just the runs around the gap.
Core's the hard part and it's right.
| } | ||
|
|
||
| pub(crate) fn resolve_args() -> Result<Args> { | ||
| let matches = CliArgs::command().get_matches(); |
There was a problem hiding this comment.
get_matches() lets clap exit usage errors with code 2, which is EXIT_VERIFICATION_FAILED in the contract, so a typo'd flag or bad --mode reads as a PoH failure in CI. Every other arg error already exits 1. try_get_matches() and map help/version to 0, everything else to 1.
| } | ||
| }); | ||
|
|
||
| let anchors: HashMap<u64, [u8; 32]> = args.anchors.iter().copied().collect(); |
There was a problem hiding this comment.
Anchors aren't part of the checkpoint descriptor, so on resume the walk starts at next_start and any anchor below the cursor is never observed. It lands in anchors_unchecked, which trips has_unverifiable() and exits 3 (report.rs:214). On a long run the anchor's usually already behind the cursor, so a clean resume comes back unverifiable. I'd persist the checked anchors in the checkpoint and re-seed on resume. (Just filtering anchors below the cursor also drops legit out-of-range anchors on a fresh run.)
| fetch_transactions: bool, | ||
| need_parent_seed: bool, | ||
| ) -> Result<WindowData> { | ||
| let blocks = db.fetch_blocks(start, end).await?; |
There was a problem hiding this comment.
These four queries run one after another; only parent_seed depends on blocks, so the other three can try_join!. Biggest win in structural mode, where there's no compute to hide the fetch behind.
| /// Detect ReplacingMergeTree duplicates whose contents actually differ. | ||
| /// `LIMIT 1 BY` picks an arbitrary variant, so conflicting duplicates | ||
| /// could otherwise shadow good data non-deterministically. | ||
| pub(crate) async fn fetch_duplicate_conflicts( |
There was a problem hiding this comment.
This re-scans entries and transactions a second time per window, roughly doubling read work over a long run. Could fold it into the main scan or make it opt-in.
| .build() | ||
| .context("build verification thread pool")?; | ||
|
|
||
| let (sender, mut receiver) = tokio::sync::mpsc::channel::<Result<WindowData>>(args.fetch_ahead); |
There was a problem hiding this comment.
window_slots × fetch_ahead sets peak memory, and windows land via fetch_all with no byte cap. Fine at defaults, but bumping window_slots to cut query overhead is an easy OOM. Worth documenting the relationship near the flags.
|
|
||
| async fn resolve_range(args: &Args, db: &ChDb, epochs: &EpochSlots) -> Result<(u64, u64)> { | ||
| if args.full { | ||
| let Some((min_slot, max_slot)) = db.fetch_bounds().await? else { |
There was a problem hiding this comment.
--full takes range_end from the live tip and pins it in the descriptor, and load_for_resume requires an exact match. On a still-ingesting cluster the tip grows between runs, the descriptor differs, and it bails exit 1 and drops the checkpoint, which is the genesis-to-tip resume case the README leads with. For --full the tip is inherently mobile, so job identity shouldn't include range_end on resume, resolve it fresh and continue to the new tip.
New workspace crate crates/superbank-verify that validates Solana Proof-of-History from the ClickHouse tables superbank already populates (blocks_metadata, entries, transactions), for genesis-to-tip or arbitrary slot/epoch ranges.