Skip to content

feat: add superbank-verify PoH validation binary#54

Open
notwedtm wants to merge 1 commit into
mainfrom
feat/superbank-verify
Open

feat: add superbank-verify PoH validation binary#54
notwedtm wants to merge 1 commit into
mainfrom
feat/superbank-verify

Conversation

@notwedtm

Copy link
Copy Markdown
Contributor

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

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 Mctursh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants