diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 62572dd..c9476c4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -35,6 +35,21 @@ builds: - SUPERBANK_GIT_SHA={{ .Commit }} - RUSTFLAGS=-C force-frame-pointers=yes + - id: superbank-verify + builder: rust + command: zigbuild + binary: superbank-verify + targets: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + flags: + - --release + - --locked + - --package=superbank-verify + - --bin=superbank-verify + env: + - SUPERBANK_GIT_SHA={{ .Commit }} + archives: - id: superbank ids: [superbank] @@ -48,6 +63,12 @@ archives: formats: [tar.gz] files: [] + - id: superbank-verify + ids: [superbank-verify] + name_template: superbank-verify-{{ .Tag }}-{{ .Os }}-{{ .Arch }} + formats: [tar.gz] + files: [] + checksum: name_template: SHA256SUMS.txt diff --git a/AGENTS.md b/AGENTS.md index e8a5596..721a15b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## Project Structure & Module Organization -- Rust workspace (root `Cargo.toml`): `superbank-workspace`, `crates/superbank/` (ingestor), and `crates/superbank-rpc/` (JSON-RPC server; bin: `superbank-rpc`). +- Rust workspace (root `Cargo.toml`): `superbank-workspace`, `crates/superbank/` (ingestor), `crates/superbank-rpc/` (JSON-RPC server; bin: `superbank-rpc`), and `crates/superbank-verify/` (Proof-of-History validator). - ClickHouse DDL: `ddl/` - Load tests: `tests/k6/` - Helper scripts: `scripts/` @@ -13,7 +13,7 @@ Config lives in `superbank.example.yaml`; copy to `superbank.yaml` for local run ## Build, Test, and Development Commands - Build (release): - `cargo build --release -p superbank -p superbank-rpc` + `cargo build --release -p superbank -p superbank-rpc -p superbank-verify` - Run ingestor with local config: `cargo run -p superbank -- --config superbank.yaml` - Run RPC server (local ClickHouse): diff --git a/CLAUDE.md b/CLAUDE.md index ba8561a..05a1d9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,10 @@ Superbank is a Rust workspace that ingests Solana ledger data into ClickHouse an Source [Fumarole / gRPC / RPC / Bigtable] --> superbank (ingestor) --> ClickHouse --> superbank-rpc (JSON-RPC + optional gRPC server) ``` -Two main crates in the workspace: +Three crates in the workspace: - **`crates/superbank`** — Ingestor binary. Pulls Solana data from Yellowstone Fumarole, Yellowstone gRPC (DragonsMouth), Solana JSON-RPC (`getBlock`), or Solana Bigtable and writes to ClickHouse. - **`crates/superbank-rpc`** — Axum-based JSON-RPC server. Reads from ClickHouse and serves Solana-compatible RPC. Has optional `grpc-head-cache`, `disk-cache`, `grpc-streaming`, and `pyroscope` features. +- **`crates/superbank-verify`** — Proof-of-History validator. Recomputes the PoH hash chain (or cheap structural invariants) from the stored `blocks_metadata`/`entries`/`transactions` tables for genesis-to-tip or arbitrary slot/epoch ranges. Other key paths: - `ddl/` — ClickHouse schemas (local, cluster, replicated variants) @@ -25,8 +26,8 @@ Other key paths: ## Build & Development Commands ```bash -# Build both crates -cargo build -p superbank -p superbank-rpc +# Build the workspace binaries +cargo build -p superbank -p superbank-rpc -p superbank-verify # Run ingestor cargo run -p superbank -- --config superbank.yaml diff --git a/Cargo.lock b/Cargo.lock index 6db54a4..292f91d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,6 +69,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "agave-feature-set" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a36f13a213d45f45f8ff87ea9fc6b0a792a7997c76b7c5d6d4a2ebe741d19d0" +dependencies = [ + "ahash", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher", + "solana-svm-feature-set 3.1.10", +] + [[package]] name = "agave-feature-set" version = "4.0.0-rc.0" @@ -81,7 +95,7 @@ dependencies = [ "solana-keypair", "solana-pubkey 4.1.0", "solana-sha256-hasher", - "solana-svm-feature-set", + "solana-svm-feature-set 4.0.0-rc.0", ] [[package]] @@ -90,11 +104,71 @@ version = "4.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b4ca5cad691b655986da68c0bf4a13f116e3ff0a1df315b854d447338f95f21" dependencies = [ - "agave-feature-set", + "agave-feature-set 4.0.0-rc.0", "solana-pubkey 4.1.0", "solana-sdk-ids", ] +[[package]] +name = "agave-syscalls" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "108ce050e29fa68a49928213a74f12fc314f8f6964cceeec908be700f1ae5a80" +dependencies = [ + "bincode", + "libsecp256k1", + "num-traits", + "solana-account", + "solana-account-info", + "solana-big-mod-exp", + "solana-blake3-hasher", + "solana-bn254", + "solana-clock", + "solana-cpi", + "solana-curve25519", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keccak-hasher", + "solana-loader-v3-interface", + "solana-poseidon", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf 0.13.1", + "solana-sdk-ids", + "solana-secp256k1-recover", + "solana-sha256-hasher", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set 3.1.10", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-type-overrides", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context 3.1.10", + "thiserror 2.0.18", +] + +[[package]] +name = "agave-transaction-view" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dc12186a81042cd93d5199adb469bdc7215968dda6c59f778a02eaa3aac7896" +dependencies = [ + "solana-hash 3.1.0", + "solana-message", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-svm-transaction", + "solana-transaction-context 3.1.10", +] + [[package]] name = "ahash" version = "0.8.12" @@ -132,6 +206,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -206,6 +286,247 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec 0.4.2", + "ark-ff 0.4.2", + "ark-std 0.4.0", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-std 0.5.0", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff 0.4.2", + "ark-poly 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint 0.4.6", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive 0.4.2", + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -456,6 +777,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "base64" version = "0.13.1" @@ -518,6 +845,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -531,7 +861,16 @@ dependencies = [ "cfg-if", "constant_time_eq", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", ] [[package]] @@ -707,6 +1046,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "caps" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" +dependencies = [ + "libc", +] + [[package]] name = "cc" version = "1.2.57" @@ -1147,6 +1495,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1188,7 +1542,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rand_core 0.6.4", "rustc_version", @@ -1326,6 +1680,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1349,13 +1714,22 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -1372,6 +1746,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cbae11b3de8fce2a456e8ea3dada226b35fe791f0dc1d360c0941f0bb681f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1390,6 +1787,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "eager" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe71d579d1812060163dff96056261deb5bf6729b100fa2e36a68b9649ba3d3" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1397,7 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1424,7 +1827,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1438,7 +1841,19 @@ dependencies = [ "derivation-path", "ed25519-dalek", "hmac", - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -1455,7 +1870,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1481,6 +1896,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-iterator" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd242f399be1da0a5354aa462d57b4ab2b4ee0683cc552f7c007d2d12d36e94" +dependencies = [ + "enum-iterator-derive", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -1501,6 +1925,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1517,6 +1961,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fast-math" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2465292146cdfc2011350fe3b1c616ac83cf0faeedb33463ba1c332ed8948d66" +dependencies = [ + "ieee754", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1750,6 +2203,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1760,6 +2223,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1769,7 +2243,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -1895,6 +2369,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1907,6 +2390,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash", ] @@ -1952,6 +2436,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1975,7 +2465,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2364,6 +2854,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ieee754" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9007da9cacbd3e6343da136e98b0d2df013f553d35bdec8b518f07bea768e19c" + [[package]] name = "indexmap" version = "1.9.3" @@ -2458,6 +2954,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2540,7 +3045,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", "signature", ] @@ -2634,6 +3139,52 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "libsecp256k1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" +dependencies = [ + "arrayref", + "base64 0.12.3", + "digest 0.9.0", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.7.3", + "serde", + "sha2 0.9.9", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" +dependencies = [ + "libsecp256k1-core", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -2645,6 +3196,30 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "light-poseidon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9a85a9752c549ceb7578064b4ed891179d20acd85f27318573b64d2d7ee7ee" +dependencies = [ + "ark-bn254 0.4.0", + "ark-ff 0.4.2", + "num-bigint 0.4.6", + "thiserror 1.0.69", +] + +[[package]] +name = "light-poseidon" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a1ccadd0bb5a32c196da536fd72c59183de24a055f6bf0513bf845fefab862" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ff 0.5.0", + "num-bigint 0.4.6", + "thiserror 1.0.69", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2820,7 +3395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2879,6 +3454,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -2898,6 +3485,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-bigint 0.2.6", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2908,6 +3520,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2934,6 +3556,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-bigint 0.2.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2943,6 +3587,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -3096,7 +3750,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -3105,6 +3759,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "percentage" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" +dependencies = [ + "num", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -3266,7 +3929,7 @@ dependencies = [ "findshlibs", "libc", "log", - "nix", + "nix 0.27.1", "once_cell", "parking_lot", "smallvec", @@ -3415,7 +4078,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap 0.10.1", @@ -3605,6 +4268,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "qualifier_attr" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quanta" version = "0.12.6" @@ -3615,7 +4289,7 @@ dependencies = [ "libc", "once_cell", "raw-cpuid", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "web-sys", "winapi", ] @@ -3697,6 +4371,19 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.5" @@ -3712,10 +4399,20 @@ dependencies = [ name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -3738,6 +4435,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -3756,6 +4462,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -3765,6 +4480,26 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4423,7 +5158,20 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -4434,7 +5182,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -4449,7 +5197,7 @@ version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "digest", + "digest 0.10.7", "keccak", ] @@ -4484,7 +5232,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4610,7 +5358,7 @@ dependencies = [ "solana-slot-history", "solana-stake-interface", "solana-sysvar", - "solana-vote-interface", + "solana-vote-interface 5.1.1", "spl-generic-token", "spl-token-2022-interface", "spl-token-group-interface", @@ -4714,11 +5462,22 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "solana-define-syscall 3.0.0", ] +[[package]] +name = "solana-bincode" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d" +dependencies = [ + "bincode", + "serde_core", + "solana-instruction-error", +] + [[package]] name = "solana-blake3-hasher" version = "3.1.0" @@ -4730,6 +5489,21 @@ dependencies = [ "solana-hash 4.3.0", ] +[[package]] +name = "solana-bn254" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ff13a8867fcc7b0f1114764e1bf6191b4551dcaf93729ddc676cd4ec6abc9f" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "bytemuck", + "solana-define-syscall 5.0.0", + "thiserror 2.0.18", +] + [[package]] name = "solana-borsh" version = "3.0.1" @@ -4739,6 +5513,53 @@ dependencies = [ "borsh", ] +[[package]] +name = "solana-bpf-loader-program" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36128e7525889b05f13a4dbfb86f1ff3aef2063ab63b899591d348226e20def8" +dependencies = [ + "agave-syscalls", + "bincode", + "qualifier_attr", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf 0.13.1", + "solana-sdk-ids", + "solana-svm-feature-set 3.1.10", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-transaction-context 3.1.10", +] + +[[package]] +name = "solana-builtins-default-costs" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f0361a1c69c869ed9c03cd7fbbacbda5e51b80d41aab5e3b2b2a21f2bca5d9" +dependencies = [ + "agave-feature-set 3.1.10", + "ahash", + "log", + "solana-bpf-loader-program", + "solana-compute-budget-program", + "solana-loader-v4-program", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-program", + "solana-vote-program", +] + [[package]] name = "solana-clock" version = "3.0.1" @@ -4771,6 +5592,57 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "solana-compute-budget" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c346408840a596e128b1b5214fd72a2c1cf8484cc96965e10333f6a18fcb897b" +dependencies = [ + "solana-fee-structure", + "solana-program-runtime", +] + +[[package]] +name = "solana-compute-budget-instruction" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5247f6c5a646681ed98f8f92b01d18ae75a22311ee00415ed2970d60dfcd610" +dependencies = [ + "agave-feature-set 3.1.10", + "log", + "solana-borsh", + "solana-builtins-default-costs", + "solana-compute-budget", + "solana-compute-budget-interface", + "solana-instruction", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-transaction", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-compute-budget-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" +dependencies = [ + "borsh", + "solana-instruction", + "solana-sdk-ids", +] + +[[package]] +name = "solana-compute-budget-program" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb474148341578c7aebd3a6d42e2f5b3be0c49ed28eb325460966d63073c9498" +dependencies = [ + "solana-program-runtime", +] + [[package]] name = "solana-config-interface" version = "2.0.0" @@ -4845,6 +5717,37 @@ dependencies = [ "uriparse", ] +[[package]] +name = "solana-entry" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48792916e0c4c136746cd7176445787c2765a2517636068fd7574571f359878a" +dependencies = [ + "bincode", + "crossbeam-channel", + "dlopen2", + "log", + "num_cpus", + "rand 0.8.5", + "rayon", + "serde", + "solana-address 1.1.0", + "solana-hash 3.1.0", + "solana-measure", + "solana-merkle-tree", + "solana-message", + "solana-metrics 3.1.10", + "solana-packet", + "solana-perf", + "solana-runtime-transaction", + "solana-sha256-hasher", + "solana-short-vec", + "solana-signature", + "solana-transaction", + "solana-transaction-error", + "wincode 0.1.2", +] + [[package]] name = "solana-epoch-info" version = "3.1.0" @@ -5118,6 +6021,62 @@ dependencies = [ "solana-system-interface 2.0.0", ] +[[package]] +name = "solana-loader-v4-interface" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c948b33ff81fa89699911b207059e493defdba9647eaf18f23abdf3674e0fb" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-interface 2.0.0", +] + +[[package]] +name = "solana-loader-v4-program" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f21fc8ee1d0f22ff002542f352eb5c38128dfc2dbc700be1db23251ecc3e78c" +dependencies = [ + "log", + "solana-account", + "solana-bincode", + "solana-bpf-loader-program", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf 0.13.1", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-transaction-context 3.1.10", +] + +[[package]] +name = "solana-measure" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee6df5d71fbc041b3badfd014753b76c643f5fcb9bba2498aae2a30d40616d0" + +[[package]] +name = "solana-merkle-tree" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c1740fa381139b6b82d72fd5fde1e547a310cf0c2d73ed5358407e3242b593c" +dependencies = [ + "fast-math", + "solana-hash 3.1.0", + "solana-sha256-hasher", +] + [[package]] name = "solana-message" version = "3.1.0" @@ -5138,6 +6097,22 @@ dependencies = [ "solana-transaction-error", ] +[[package]] +name = "solana-metrics" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0f05dfe08e1b14429eb35feb15f89455d7c75d8a1704ee8da1f0ebf0993d42" +dependencies = [ + "crossbeam-channel", + "gethostname 0.2.3", + "log", + "reqwest 0.12.28", + "solana-cluster-type", + "solana-sha256-hasher", + "solana-time-utils", + "thiserror 2.0.18", +] + [[package]] name = "solana-metrics" version = "4.0.0-rc.0" @@ -5145,7 +6120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5dbcfee87e9df46779668d55db690593ae41778b036751663c1acd81e34da97" dependencies = [ "crossbeam-channel", - "gethostname", + "gethostname 1.1.0", "log", "reqwest 0.12.28", "solana-cluster-type", @@ -5183,6 +6158,18 @@ dependencies = [ "solana-sha256-hasher", ] +[[package]] +name = "solana-nonce-account" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805fd25b29e5a1a0e6c3dd6320c9da80f275fbe4ff6e392617c303a2085c435e" +dependencies = [ + "solana-account", + "solana-hash 3.1.0", + "solana-nonce", + "solana-sdk-ids", +] + [[package]] name = "solana-offchain-message" version = "3.0.0" @@ -5205,7 +6192,68 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edf2f25743c95229ac0fdc32f8f5893ef738dbf332c669e9861d33ddb0f469d" dependencies = [ + "bincode", "bitflags 2.11.0", + "cfg_eval", + "serde", + "serde_derive", + "serde_with", +] + +[[package]] +name = "solana-perf" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cae20efb5ec6c98b3932d6755d5df962ab51344f099d704317a79af2faa42403" +dependencies = [ + "ahash", + "bincode", + "bv", + "bytes", + "caps", + "curve25519-dalek", + "dlopen2", + "fnv", + "libc", + "log", + "nix 0.30.1", + "rand 0.8.5", + "rayon", + "serde", + "solana-hash 3.1.0", + "solana-message", + "solana-metrics 3.1.10", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-rayon-threadlimit", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-time-utils", + "solana-transaction-context 3.1.10", +] + +[[package]] +name = "solana-poseidon" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a2c8f53a80947785b58e55bda1b619bac03f266c9055b7b0208775f0522057" +dependencies = [ + "ark-bn254 0.4.0", + "ark-bn254 0.5.0", + "light-poseidon 0.2.0", + "light-poseidon 0.4.0", + "solana-define-syscall 3.0.0", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-precompile-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cafcd950de74c6c39d55dc8ca108bbb007799842ab370ef26cf45a34453c31e1" +dependencies = [ + "num-traits", ] [[package]] @@ -5305,12 +6353,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "362279f6e8020e4cf11313233789bf619420ad8835ebc91963ee5cec91bb05da" [[package]] -name = "solana-program-pack" -version = "3.1.0" +name = "solana-program-pack" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7701cb15b90667ae1c89ef4ac35a59c61e66ce58ddee13d729472af7f41d59" +dependencies = [ + "solana-program-error", +] + +[[package]] +name = "solana-program-runtime" +version = "3.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7701cb15b90667ae1c89ef4ac35a59c61e66ce58ddee13d729472af7f41d59" +checksum = "b9cefa43d3f60a2dd25e0fcc3a5a46f50de021c42981029b1e8f375e1d954853" dependencies = [ - "solana-program-error", + "base64 0.22.1", + "bincode", + "itertools 0.12.1", + "log", + "percentage", + "rand 0.8.5", + "serde", + "solana-account", + "solana-account-info", + "solana-clock", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-program-entrypoint", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf 0.13.1", + "solana-sdk-ids", + "solana-slot-hashes", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set 3.1.10", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-transaction", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context 3.1.10", + "thiserror 2.0.18", ] [[package]] @@ -5332,6 +6425,16 @@ dependencies = [ "solana-address 2.6.0", ] +[[package]] +name = "solana-rayon-threadlimit" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d48133891ea0e717d845059429b98d03bab34f3ec4e2b58bfe78248a898017" +dependencies = [ + "log", + "num_cpus", +] + [[package]] name = "solana-rent" version = "3.1.0" @@ -5400,7 +6503,7 @@ dependencies = [ "solana-transaction-error", "solana-transaction-status-client-types", "solana-version", - "solana-vote-interface", + "solana-vote-interface 5.1.1", "tokio", ] @@ -5452,12 +6555,51 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "solana-runtime-transaction" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85538e4376c5d8cab0fc9afa10a193edda72affa1727f56696600e3e2ffe0766" +dependencies = [ + "agave-transaction-view", + "log", + "solana-compute-budget", + "solana-compute-budget-instruction", + "solana-hash 3.1.0", + "solana-message", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-signature", + "solana-svm-transaction", + "solana-transaction", + "solana-transaction-context 3.1.10", + "solana-transaction-error", + "thiserror 2.0.18", +] + [[package]] name = "solana-sanitize" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" +[[package]] +name = "solana-sbpf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15b079e08471a9dbfe1e48b2c7439c85aa2a055cbd54eddd8bd257b0a7dbb29" +dependencies = [ + "byteorder", + "combine 3.8.1", + "hash32", + "libc", + "log", + "rand 0.8.5", + "rustc-demangle", + "thiserror 2.0.18", + "winapi", +] + [[package]] name = "solana-sbpf" version = "0.14.4" @@ -5559,7 +6701,7 @@ checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" dependencies = [ "hmac", "pbkdf2", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -5597,7 +6739,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" dependencies = [ - "sha2", + "sha2 0.10.9", "solana-define-syscall 4.0.1", "solana-hash 4.3.0", ] @@ -5715,7 +6857,7 @@ dependencies = [ "bincode", "bytes", "bzip2", - "enum-iterator", + "enum-iterator 2.3.0", "flate2", "futures", "goauth", @@ -5730,7 +6872,7 @@ dependencies = [ "smpl_jwt", "solana-clock", "solana-message", - "solana-metrics", + "solana-metrics 4.0.0-rc.0", "solana-pubkey 4.1.0", "solana-serde", "solana-signature", @@ -5764,18 +6906,85 @@ dependencies = [ "solana-serde", "solana-signature", "solana-transaction", - "solana-transaction-context", + "solana-transaction-context 4.0.0-rc.0", "solana-transaction-error", "solana-transaction-status", "tonic-build 0.9.2", ] +[[package]] +name = "solana-svm-callback" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9114cc1391e57d0d6902c7c347964cb8e41e3c141ebb4be3ec5440be1c994fa" +dependencies = [ + "solana-account", + "solana-clock", + "solana-precompile-error", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "solana-svm-feature-set" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62a20c4fc8d409780c4592c17ac3e01b6f3dc949e6ffd3acbda6d2a21e67b53a" + [[package]] name = "solana-svm-feature-set" version = "4.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ada78631678a5a5139d6491bb8729143192c6afd30945dd8cddd974b174d7f7" +[[package]] +name = "solana-svm-log-collector" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92f14f80cf719d5bc2c14bbee50075920637d1f9afe2fce990e5394fa579293" +dependencies = [ + "log", +] + +[[package]] +name = "solana-svm-measure" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c68915dfa801dd03b3f7c3e0a510fe233754463e77f5afe6a30a51ac9fa326" + +[[package]] +name = "solana-svm-timings" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a98d45197b57ae84bbf97647840adb153e444ad147638e742929d83343f48e" +dependencies = [ + "eager", + "enum-iterator 1.5.0", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "solana-svm-transaction" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6074e3b4bdd7ade15db51a4f309eb9e22239d33b01f4ea5ecf6aff8f81d0ba" +dependencies = [ + "solana-hash 3.1.0", + "solana-message", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-signature", + "solana-transaction", +] + +[[package]] +name = "solana-svm-type-overrides" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b2ea7c2f849cd6d190e2607c2af779d3dad2792784cc13c0e9342e429c87a1" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "solana-system-interface" version = "2.0.0" @@ -5806,6 +7015,32 @@ dependencies = [ "solana-program-error", ] +[[package]] +name = "solana-system-program" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d88343aac5ad97d240eca299c8dfcde833e861b6a40bff6fda3ac215efc211" +dependencies = [ + "bincode", + "log", + "serde", + "solana-account", + "solana-bincode", + "solana-fee-calculator", + "solana-instruction", + "solana-nonce", + "solana-nonce-account", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-transaction-context 3.1.10", +] + [[package]] name = "solana-sysvar" version = "3.1.1" @@ -5878,6 +7113,23 @@ dependencies = [ "solana-transaction-error", ] +[[package]] +name = "solana-transaction-context" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be7c191d89fb883fef0b4bb4225121f7ad14eb5664d5dc9707b4af661e21924c" +dependencies = [ + "bincode", + "serde", + "solana-account", + "solana-instruction", + "solana-instructions-sysvar", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf 0.13.1", + "solana-sdk-ids", +] + [[package]] name = "solana-transaction-context" version = "4.0.0-rc.0" @@ -5891,7 +7143,7 @@ dependencies = [ "solana-instructions-sysvar", "solana-pubkey 4.1.0", "solana-rent 3.1.0", - "solana-sbpf", + "solana-sbpf 0.14.4", "solana-sdk-ids", ] @@ -5941,7 +7193,7 @@ dependencies = [ "solana-transaction", "solana-transaction-error", "solana-transaction-status-client-types", - "solana-vote-interface", + "solana-vote-interface 5.1.1", "spl-associated-token-account-interface", "spl-memo-interface", "spl-token-2022-interface", @@ -5970,7 +7222,7 @@ dependencies = [ "solana-reward-info", "solana-signature", "solana-transaction", - "solana-transaction-context", + "solana-transaction-context 4.0.0-rc.0", "solana-transaction-error", "thiserror 2.0.18", ] @@ -5981,7 +7233,7 @@ version = "4.0.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05a72745825b1cf786bfd14024a0f52e15b5b95c7eb58152af6608804cd0fa95" dependencies = [ - "agave-feature-set", + "agave-feature-set 4.0.0-rc.0", "rand 0.9.2", "semver", "serde", @@ -5989,6 +7241,32 @@ dependencies = [ "solana-serde-varint", ] +[[package]] +name = "solana-vote-interface" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6e123e16bfdd7a81d71b4c4699e0b29580b619f4cd2ef5b6aae1eb85e8979f" +dependencies = [ + "bincode", + "cfg_eval", + "num-derive", + "num-traits", + "serde", + "serde_derive", + "serde_with", + "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-serde-varint", + "solana-serialize-utils", + "solana-short-vec", + "solana-system-interface 2.0.0", +] + [[package]] name = "solana-vote-interface" version = "5.1.1" @@ -6015,6 +7293,38 @@ dependencies = [ "solana-system-interface 3.2.0", ] +[[package]] +name = "solana-vote-program" +version = "3.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3d7ddb2f7585dcc2225dec9991b15a76c2b80c3edad8f4d5b563fd6b7a2f25" +dependencies = [ + "agave-feature-set 3.1.10", + "bincode", + "log", + "num-derive", + "num-traits", + "serde", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keypair", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-signer", + "solana-slot-hashes", + "solana-transaction", + "solana-transaction-context 3.1.10", + "solana-vote-interface 4.0.4", + "thiserror 2.0.18", +] + [[package]] name = "solana-zk-sdk" version = "4.0.0" @@ -6104,7 +7414,7 @@ checksum = "5d1dbc82ab91422345b6df40a79e2b78c7bce1ebb366da323572dd60b7076b67" dependencies = [ "proc-macro2", "quote", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "thiserror 1.0.69", ] @@ -6388,7 +7698,7 @@ dependencies = [ "solana-rpc-client-api", "solana-rpc-client-types", "solana-sdk", - "solana-transaction-context", + "solana-transaction-context 4.0.0-rc.0", "solana-transaction-status", "tempfile", "thiserror 2.0.18", @@ -6407,6 +7717,38 @@ dependencies = [ "yellowstone-grpc-proto", ] +[[package]] +name = "superbank-verify" +version = "0.5.0" +dependencies = [ + "anyhow", + "axum 0.8.8", + "bs58", + "clap", + "clickhouse", + "kubert-prometheus-process", + "once_cell", + "prometheus-client", + "prometheus-client-derive-encode", + "rayon", + "serde", + "serde-big-array", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "solana-entry", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-merkle-tree", + "solana-message", + "solana-signature", + "solana-transaction", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "superbank-workspace" version = "0.5.0" @@ -7211,6 +8553,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -7422,6 +8770,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "wincode" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5067322fecd19471f7980888bff95cedf08b19829c83418f51410ff9ccc4193" +dependencies = [ + "proc-macro2", + "quote", + "solana-short-vec", + "thiserror 2.0.18", + "wincode-derive 0.1.1", +] + [[package]] name = "wincode" version = "0.2.5" @@ -7448,6 +8809,18 @@ dependencies = [ "wincode-derive 0.4.4", ] +[[package]] +name = "wincode-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a144d1576a6d65f9c80df1d531e12b197057c6f69a6e9d4a183fe61e9f135568" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "wincode-derive" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 775833f..403933a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,12 @@ authors = [ members = [ "crates/superbank", "crates/superbank-rpc", + "crates/superbank-verify", ] resolver = "2" default-members = [ "crates/superbank", "crates/superbank-rpc", + "crates/superbank-verify", ] diff --git a/Dockerfile b/Dockerfile index d5ddcd5..05a39a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,11 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ fi \ && cp target/release/superbank-rpc /app/superbank-rpc +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked -p superbank-verify \ + && cp target/release/superbank-verify /app/superbank-verify + FROM debian:bookworm-slim AS runtime RUN apt-get update \ @@ -46,9 +51,10 @@ RUN apt-get update \ COPY --from=builder /app/superbank /usr/local/bin/superbank COPY --from=builder /app/superbank-rpc /usr/local/bin/superbank-rpc +COPY --from=builder /app/superbank-verify /usr/local/bin/superbank-verify USER superbank -EXPOSE 8899 9900 9901 +EXPOSE 8899 9900 9901 9902 ENTRYPOINT ["/usr/local/bin/superbank-rpc"] diff --git a/README.md b/README.md index 17b2b09..e8d08f0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Ingest Solana ledger data into ClickHouse and serve Solana-compatible JSON-RPC from that data. -[Ingestor](crates/superbank/README.md) · [RPC server](crates/superbank-rpc/README.md) · [ClickHouse DDL](ddl/) · [k6 tests](tests/k6/README.md) +[Ingestor](crates/superbank/README.md) · [RPC server](crates/superbank-rpc/README.md) · [PoH validator](crates/superbank-verify/README.md) · [ClickHouse DDL](ddl/) · [k6 tests](tests/k6/README.md) @@ -28,6 +28,7 @@ Solana-compatible JSON-RPC endpoints backed by that data. - Ingest from Yellowstone Fumarole, Yellowstone gRPC (DragonsMouth), Solana JSON-RPC (`getBlock`), or Solana Bigtable - Store blocks + transactions in ClickHouse (`ddl/`) - Serve Solana-compatible JSON-RPC backed by ClickHouse (`crates/superbank-rpc`) +- Validate Proof-of-History over the stored data (`crates/superbank-verify`) - Optionally expose ClickHouse-backed gRPC block and transaction streams (`--features grpc-streaming`) - k6 load + validation scenarios for supported RPC methods (`tests/k6/`) @@ -38,6 +39,7 @@ flowchart LR Source[Fumarole / gRPC / RPC / Bigtable] --> Ingest[superbank] Ingest --> CH[ClickHouse] CH --> RPC[superbank-rpc] + CH --> Verify[superbank-verify] ``` ## Table of contents @@ -244,7 +246,8 @@ Build the production image from the repo root: docker build -t superbank:0.5.0 . ``` -The image contains both binaries. It runs `superbank-rpc` by default: +The image contains all three binaries (`superbank`, `superbank-rpc`, +`superbank-verify`). It runs `superbank-rpc` by default: ```bash docker run --rm -p 8899:8899 \ @@ -343,6 +346,7 @@ rows already present on the target by exact table key instead of failing the run - `crates/superbank` ingestor binary (Yellowstone Fumarole, Yellowstone gRPC, Solana JSON-RPC, or Solana Bigtable sources) - `crates/superbank-rpc` Solana-compatible JSON-RPC server backed by ClickHouse +- `crates/superbank-verify` Proof-of-History validator for the stored data (genesis-to-tip or arbitrary slot/epoch ranges) - `ddl/` ClickHouse schemas (transactions, block metadata, optional PoH entries, GSFA/signatures, token owner activity) - `tests/k6/` load/validation tests for `superbank-rpc` - `scripts/` helper scripts (local runs, analysis, k6 orchestration) @@ -352,7 +356,7 @@ rows already present on the target by exact table key instead of failing the run ## Development ```bash -cargo build -p superbank -p superbank-rpc +cargo build -p superbank -p superbank-rpc -p superbank-verify cargo fmt --all -- --check cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/crates/superbank-verify/Cargo.toml b/crates/superbank-verify/Cargo.toml new file mode 100644 index 0000000..0b51faa --- /dev/null +++ b/crates/superbank-verify/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "superbank-verify" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +anyhow = "1.0" +axum = "0.8" +bs58 = "0.5" +clap = { version = "4.5.40", features = ["derive", "env"] } +clickhouse = { version = "0.14" } +kubert-prometheus-process = "0.2.3" +once_cell = "1.21.3" +prometheus-client = "0.23.1" +prometheus-client-derive-encode = "0.4.2" +rayon = "1.10" +serde = { version = "1.0.219", features = ["derive"] } +serde-big-array = "0.5" +serde_json = "1.0" +serde_yaml = "0.9" +sha2 = "0.10" +solana-epoch-schedule = "3.0.0" +tokio = { version = "1.46.1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +solana-entry = { version = "3.0.0", features = ["agave-unstable-api"] } +solana-hash = "3.1.0" +solana-merkle-tree = "3.0.0" +solana-message = "3.1.0" +solana-signature = "3.4.0" +solana-transaction = { version = "3.1.0", features = ["serde"] } +tempfile = "3" diff --git a/crates/superbank-verify/README.md b/crates/superbank-verify/README.md new file mode 100644 index 0000000..2ebf720 --- /dev/null +++ b/crates/superbank-verify/README.md @@ -0,0 +1,142 @@ +# superbank-verify + +Validates Solana **Proof-of-History** from the data superbank stores in +ClickHouse — genesis-to-tip or any slot/epoch range. It recomputes the SHA-256 +PoH chain over the `entries` table (full mode) or checks the cheap structural +invariants Agave enforces during replay (structural mode), and verifies +blockhash linkage across blocks either way. + +The verifier core is a port of Agave's `solana-entry` +(`next_hash`/`hash_transactions`) and is differentially tested against that +crate. + +## Data requirements + +| Table | Used for | Required | +|---|---|---| +| `blocks_metadata` | blockhash / parent linkage, entry & tx counts | always | +| `entries` | per-entry `num_hashes`, hash, tx index ranges | both modes | +| `transactions` (`tx_signatures`) | entry mixin (merkle root of signatures) | full mode only | + +Entries are populated by the **Fumarole**, **Yellowstone gRPC**, and +**Jetstreamer** ingest paths. Ranges ingested via JSON-RPC or Bigtable have no +`entries` rows: those slots are reported as `unverifiable`, not failures. + +## Usage + +```bash +# Structural sweep over everything present in blocks_metadata +superbank-verify --full + +# Full PoH recomputation for a slot range +superbank-verify --range 250000000:250001000 --mode full + +# Epochs 700-701 (mainnet warmup schedule is applied for epochs < 14) +superbank-verify --range 700-701 --mode full + +# Multi-day genesis-to-tip run with resume + JSONL findings report +superbank-verify --full --mode full \ + --checkpoint-file verify.checkpoint.json --resume \ + --report-file findings.jsonl +``` + +Range grammar (same as the ingestor's `--bigtable-range`): `a:b` = slots +(inclusive), `a-b` = epochs, `a` = a single epoch. + +### Modes + +- **structural** (default): no hash recomputation. Checks entry counts and + index contiguity, tick counts (`(slot - parent_slot) * ticks_per_slot`, + including ticks carried for skipped slots), the trailing-tick rule, a + replica of Agave's `verify_tick_hash_count` (per-tick `num_hashes` windows + against the era's `hashes_per_tick`), transaction-index tiling, the + last-entry-hash == blockhash equality, duplicate conflicts, and blockhash + chain linkage. +- **full**: everything above **plus** recomputing every SHA-256 hash of every + entry, including the transaction-signature merkle mixin, and comparing + against the recorded entry hashes and blockhash. Roughly 800k hashes per + slot in the 12,500 hashes-per-tick era (~4M in the current 62,500 era); + budget multiple days for a genesis-to-tip run on a large machine. + +### hashes_per_tick eras + +`--hashes-per-tick-schedule` defaults to the built-in mainnet history +(12,500 at genesis, then the `update_hashes_per_tick2..6` feature activations +at slots 253584001 / 255312004 / 255744008 / 257040000 / 257904000 up to +62,500; see `src/eras.rs` for provenance). Override for other clusters with +`":,..."`; a value of `0` disables the tick-hash-count check +for that era. + +### Trust model + +Full mode proves each block's PoH is internally consistent and chained from +its recorded `parent_blockhash`; chain linkage extends that to the whole +range. A fabricated-but-self-consistent segment after a coverage gap is only +detectable against an external reference: pass one or more +`--anchor :` (e.g. from a trusted RPC's `getBlock`) +to pin recorded blockhashes, and `--expected-genesis-hash` (defaults to +mainnet's `5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d`; pass `""` to +disable) to pin slot 0. PoH commits only to transaction *signatures* — message +contents are outside its proof and are not checked by this tool. + +### Findings and statuses + +Every slot in the range ends up `ok`, `failed`, `unverifiable`, `skipped` +(chain skipped it), or `missing` (absent from our data with no skip claim). +Findings are logged and, with `--report-file`, written as JSONL with codes +like `entry_hash_mismatch`, `blockhash_mismatch`, `chain_break`, +`tick_count_mismatch`, `tick_hash_count_mismatch`, `tx_index_mismatch`, +`duplicate_conflict`, `num_hashes_out_of_range`, `missing_entries`, +`anchor_mismatch`, `anchor_not_checked` (an anchor whose slot never met a +block is reported as unverifiable, not silently ignored). + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | every slot ok (or only skipped) | +| 1 | operational error (bad config, ClickHouse unreachable, ...) | +| 2 | at least one verification failure | +| 3 | no failures, but unverifiable/missing slots (suppress with `--allow-unverifiable`) | + +### Resume + +`--checkpoint-file` saves progress after every window (atomic rename); +`--resume` continues an interrupted run as long as the job parameters (range, +mode, tables, era schedule) are identical. The findings report is appended on +resume, truncated otherwise. + +### Metrics + +Prometheus metrics on `:9902` (`/metrics`, `/health`): slots processed by +status, findings by code, entries/hashes verified, window durations, fetch +latencies. `/health` returns 503 when no window completed within +`--health-stale-secs`. + +## Configuration + +CLI flags > environment variables > YAML config (`--config` / +`SUPERBANK_VERIFY_CONFIG`) > defaults. See `superbank-verify.example.yaml` at +the repo root and `superbank-verify --help` for the full reference. ClickHouse +connection uses the same `CLICKHOUSE_URL` / `CLICKHOUSE_DATABASE` / +`CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` variables as the other binaries; +table names default to `default.blocks_metadata`, `default.entries`, +`default.transactions`. + +## Test fixtures + +`--export-fixture-slot --export-fixture-out ` dumps one slot's +block metadata, entries, and per-transaction signatures as JSON — useful for +capturing real mainnet blocks (era boundaries, skipped-slot successors) as +golden vectors. + +## Notes + +- ReplacingMergeTree duplicates are deduplicated with `LIMIT 1 BY`; rows whose + duplicates *differ* are reported as `duplicate_conflict` since no + deterministic winner exists. +- Not yet handled: the Alpenglow migration (post-PoH tick rules on Agave + master) — revisit the era schedule when it activates on a target cluster. +- `getEpochSchedule` in superbank-rpc ignores mainnet's warmup epochs; this + crate carries its own epoch math (`src/epoch.rs`) instead of sharing that + code. diff --git a/crates/superbank-verify/src/chain.rs b/crates/superbank-verify/src/chain.rs new file mode 100644 index 0000000..d783207 --- /dev/null +++ b/crates/superbank-verify/src/chain.rs @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Cross-block chain-linkage walk. Blocks are observed in ascending slot +//! order; the walk carries the previous present block and classifies every +//! slot between consecutive present blocks as skipped (claimed by the chain +//! via `parent_slot`) or missing (absent from our data with no skip claim). + +use std::collections::HashMap; + +use crate::report::{Finding, FindingCode}; +use crate::verify::poh::Hash32; +use crate::verify::{BlockInfo, b58}; + +#[derive(Debug, Default)] +pub(crate) struct ObserveResult { + pub(crate) findings: Vec, + /// Inclusive span of slots the chain skipped (no block exists on chain). + pub(crate) skipped_span: Option<(u64, u64)>, + /// Inclusive span of slots absent from our data with unknown chain state. + pub(crate) missing_span: Option<(u64, u64)>, +} + +pub(crate) struct ChainWalk { + /// Last present block observed: (slot, blockhash). + prev: Option<(u64, Hash32)>, + /// Slots strictly below this are already accounted (range start, or the + /// checkpoint cursor after a resume). + floor: u64, + expected_genesis_hash: Option, + anchors: HashMap, + checked_anchors: std::collections::HashSet, +} + +impl ChainWalk { + pub(crate) fn new( + floor: u64, + expected_genesis_hash: Option, + anchors: HashMap, + ) -> Self { + Self { + prev: None, + floor, + expected_genesis_hash, + anchors, + checked_anchors: std::collections::HashSet::new(), + } + } + + /// Slot of the last present block observed (everything at or below it, + /// down to the floor, is fully accounted). + pub(crate) fn last_present(&self) -> Option { + self.prev.map(|(slot, _)| slot) + } + + /// Anchors that were requested but never compared against a block. + pub(crate) fn unchecked_anchors(&self) -> Vec<(u64, Hash32)> { + let mut unchecked: Vec<(u64, Hash32)> = self + .anchors + .iter() + .filter(|(slot, _)| !self.checked_anchors.contains(slot)) + .map(|(slot, hash)| (*slot, *hash)) + .collect(); + unchecked.sort_unstable_by_key(|(slot, _)| *slot); + unchecked + } + + /// Seed the carry with the parent block of the first in-range block + /// (fetched separately when the range does not start at slot 0). + pub(crate) fn seed(&mut self, slot: u64, blockhash: Hash32) { + if self.prev.is_none() { + self.prev = Some((slot, blockhash)); + } + } + + pub(crate) fn observe_block(&mut self, block: &BlockInfo) -> ObserveResult { + let mut result = ObserveResult::default(); + + if block.slot == 0 { + if block.parent_slot != 0 { + result.findings.push( + Finding::new( + 0, + FindingCode::ParentSlotMismatch, + "slot 0 must be its own parent", + ) + .with_expected_actual("0", block.parent_slot.to_string()), + ); + } + if let Some(genesis) = self.expected_genesis_hash + && block.parent_blockhash != genesis + { + result.findings.push( + Finding::new( + 0, + FindingCode::GenesisHashMismatch, + "slot 0 parent_blockhash does not match the expected genesis hash", + ) + .with_expected_actual(b58(&genesis), b58(&block.parent_blockhash)), + ); + } + } else if block.parent_slot >= block.slot { + // A block claiming itself or a future slot as parent is + // structurally impossible; interval classification and linkage + // are meaningless against such a claim. + result.findings.push( + Finding::new( + block.slot, + FindingCode::ParentSlotMismatch, + "parent_slot must be strictly below the block's own slot", + ) + .with_expected_actual(format!("< {}", block.slot), block.parent_slot.to_string()), + ); + } else { + self.classify_interval(block, &mut result); + self.check_linkage(block, &mut result); + } + + if let Some(anchor) = self.anchors.get(&block.slot) { + self.checked_anchors.insert(block.slot); + if *anchor != block.blockhash { + result.findings.push( + Finding::new( + block.slot, + FindingCode::AnchorMismatch, + "recorded blockhash does not match the externally supplied anchor", + ) + .with_expected_actual(b58(anchor), b58(&block.blockhash)), + ); + } + } + + self.prev = Some((block.slot, block.blockhash)); + result + } + + /// Account for the tail of the range after the last present block. + /// `next_block` is the first present block beyond the range end, if any. + pub(crate) fn observe_tail( + &self, + range_end: u64, + next_block: Option<&BlockInfo>, + ) -> ObserveResult { + let mut result = ObserveResult::default(); + let lower = match self.prev { + Some((slot, _)) => slot.saturating_add(1).max(self.floor), + None => self.floor, + }; + if lower > range_end { + return result; + } + + match next_block { + Some(next) => { + let claim = next.parent_slot; + let missing_hi = claim.min(range_end); + if missing_hi >= lower { + result.missing_span = Some((lower, missing_hi)); + } + let skipped_lo = claim.saturating_add(1).max(lower); + if range_end >= skipped_lo { + result.skipped_span = Some((skipped_lo, range_end)); + } + } + None => { + result.missing_span = Some((lower, range_end)); + } + } + + if let Some((lo, hi)) = result.missing_span { + result.findings.push(missing_span_finding(lo, hi)); + } + result + } + + /// Classify the slots strictly between the previous present block (or the + /// accounting floor) and `block.slot`. The chain claims everything after + /// `parent_slot` was skipped; anything at or below it that we do not hold + /// is a data gap. + fn classify_interval(&self, block: &BlockInfo, result: &mut ObserveResult) { + let lower = match self.prev { + Some((slot, _)) => slot.saturating_add(1).max(self.floor), + None => self.floor, + }; + if lower >= block.slot { + return; + } + let upper = block.slot - 1; + let claim = block.parent_slot; + + let missing_hi = claim.min(upper); + if missing_hi >= lower { + result.missing_span = Some((lower, missing_hi)); + result + .findings + .push(missing_span_finding(lower, missing_hi)); + } + + let skipped_lo = claim.saturating_add(1).max(lower); + if upper >= skipped_lo { + result.skipped_span = Some((skipped_lo, upper)); + } + } + + fn check_linkage(&self, block: &BlockInfo, result: &mut ObserveResult) { + match self.prev { + Some((prev_slot, prev_hash)) => { + if block.parent_slot == prev_slot { + if block.parent_blockhash != prev_hash { + result.findings.push( + Finding::new( + block.slot, + FindingCode::ChainBreak, + format!( + "parent_blockhash does not match blockhash of parent slot {prev_slot}" + ), + ) + .with_expected_actual(b58(&prev_hash), b58(&block.parent_blockhash)), + ); + } + } else if block.parent_slot > prev_slot { + result.findings.push(Finding::new( + block.slot, + FindingCode::MissingParent, + format!( + "parent slot {} absent from blocks_metadata; linkage unverifiable", + block.parent_slot + ), + )); + } else { + result.findings.push( + Finding::new( + block.slot, + FindingCode::ParentSlotMismatch, + format!( + "chain claims parent {} but our data holds a later block at slot {prev_slot}", + block.parent_slot + ), + ) + .with_expected_actual(prev_slot.to_string(), block.parent_slot.to_string()), + ); + } + } + None => { + // No carry: the runner seeds the walk with the fetched parent + // when it exists; reaching here means the parent is absent. + result.findings.push(Finding::new( + block.slot, + FindingCode::MissingParent, + format!( + "parent slot {} absent from blocks_metadata; linkage unverifiable", + block.parent_slot + ), + )); + } + } + } +} + +fn missing_span_finding(lo: u64, hi: u64) -> Finding { + Finding::new( + lo, + FindingCode::MissingBlock, + if lo == hi { + format!("slot {lo} absent from blocks_metadata with no skip claim covering it") + } else { + format!( + "slots {lo}..={hi} absent from blocks_metadata with no skip claim covering them" + ) + }, + ) +} + +pub(crate) fn span_len(span: Option<(u64, u64)>) -> u64 { + span.map(|(lo, hi)| hi.saturating_sub(lo) + 1).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(slot: u64, parent_slot: u64, blockhash: u8, parent_blockhash: u8) -> BlockInfo { + BlockInfo { + slot, + parent_slot, + blockhash: [blockhash; 32], + parent_blockhash: [parent_blockhash; 32], + executed_transaction_count: 0, + entry_count: 0, + } + } + + #[test] + fn contiguous_chain_links_cleanly() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + let result = walk.observe_block(&block(10, 9, 2, 1)); + assert!(result.findings.is_empty()); + let result = walk.observe_block(&block(11, 10, 3, 2)); + assert!(result.findings.is_empty()); + assert_eq!(result.skipped_span, None); + assert_eq!(result.missing_span, None); + } + + #[test] + fn skipped_slots_are_claimed_by_parent_link() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + let result = walk.observe_block(&block(14, 10, 3, 2)); + assert!(result.findings.is_empty(), "{:?}", result.findings); + assert_eq!(result.skipped_span, Some((11, 13))); + assert_eq!(result.missing_span, None); + } + + #[test] + fn broken_hash_linkage_detected() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + let result = walk.observe_block(&block(11, 10, 3, 9)); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].code, FindingCode::ChainBreak); + } + + #[test] + fn data_gap_counts_missing_and_skipped() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + // Next present block claims parent 15: slots 11..=15 are a data gap + // (15 itself should exist on chain), 16..=19 were skipped. + let result = walk.observe_block(&block(20, 15, 4, 3)); + assert_eq!(result.missing_span, Some((11, 15))); + assert_eq!(result.skipped_span, Some((16, 19))); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::MissingParent) + ); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::MissingBlock) + ); + } + + #[test] + fn parent_older_than_previous_block_is_a_mismatch() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + let result = walk.observe_block(&block(12, 8, 3, 2)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::ParentSlotMismatch) + ); + assert_eq!(result.missing_span, None); + } + + #[test] + fn unseeded_walk_reports_missing_parent_once() { + let mut walk = ChainWalk::new(100, None, HashMap::new()); + let result = walk.observe_block(&block(105, 99, 2, 1)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::MissingParent) + ); + // Slots 100..=104: parent claim is 99 (< floor), so all are skipped. + assert_eq!(result.skipped_span, Some((100, 104))); + assert_eq!(result.missing_span, None); + } + + #[test] + fn self_or_future_parent_is_a_hard_failure() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + + let result = walk.observe_block(&block(12, 12, 3, 2)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::ParentSlotMismatch) + ); + + let result = walk.observe_block(&block(14, 9000, 4, 3)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::ParentSlotMismatch) + ); + } + + #[test] + fn unchecked_anchors_are_surfaced() { + let mut anchors = HashMap::new(); + anchors.insert(10u64, [2u8; 32]); // will be checked + anchors.insert(11u64, [9u8; 32]); // slot skipped by the chain + anchors.insert(9999u64, [9u8; 32]); // outside the walked range + let mut walk = ChainWalk::new(10, None, anchors); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + walk.observe_block(&block(12, 10, 3, 2)); + + let unchecked = walk.unchecked_anchors(); + let slots: Vec = unchecked.iter().map(|(slot, _)| *slot).collect(); + assert_eq!(slots, vec![11, 9999]); + } + + #[test] + fn genesis_block_checks_hash_and_self_parent() { + let genesis = [7u8; 32]; + let mut walk = ChainWalk::new(0, Some(genesis), HashMap::new()); + let result = walk.observe_block(&block(0, 0, 2, 7)); + assert!(result.findings.is_empty(), "{:?}", result.findings); + + let mut walk = ChainWalk::new(0, Some(genesis), HashMap::new()); + let result = walk.observe_block(&block(0, 0, 2, 8)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::GenesisHashMismatch) + ); + } + + #[test] + fn anchors_checked_on_matching_slot() { + let mut anchors = HashMap::new(); + anchors.insert(10u64, [2u8; 32]); + anchors.insert(11u64, [9u8; 32]); + let mut walk = ChainWalk::new(10, None, anchors); + walk.seed(9, [1; 32]); + let result = walk.observe_block(&block(10, 9, 2, 1)); + assert!(result.findings.is_empty()); + let result = walk.observe_block(&block(11, 10, 3, 2)); + assert!( + result + .findings + .iter() + .any(|finding| finding.code == FindingCode::AnchorMismatch) + ); + } + + #[test] + fn tail_with_next_block_classifies_claim() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + // Range ends at 20; the next present block (25) claims parent 15. + let next = block(25, 15, 5, 4); + let result = walk.observe_tail(20, Some(&next)); + assert_eq!(result.missing_span, Some((11, 15))); + assert_eq!(result.skipped_span, Some((16, 20))); + + // Next block links directly to our last present block: all skipped. + let next = block(25, 10, 5, 2); + let result = walk.observe_tail(20, Some(&next)); + assert_eq!(result.missing_span, None); + assert_eq!(result.skipped_span, Some((11, 20))); + } + + #[test] + fn tail_without_next_block_is_missing() { + let mut walk = ChainWalk::new(10, None, HashMap::new()); + walk.seed(9, [1; 32]); + walk.observe_block(&block(10, 9, 2, 1)); + let result = walk.observe_tail(20, None); + assert_eq!(result.missing_span, Some((11, 20))); + assert_eq!(result.skipped_span, None); + } + + #[test] + fn empty_range_tail_accounts_from_floor() { + let walk = ChainWalk::new(10, None, HashMap::new()); + let result = walk.observe_tail(20, None); + assert_eq!(result.missing_span, Some((10, 20))); + } + + #[test] + fn span_len_counts_inclusive() { + assert_eq!(span_len(None), 0); + assert_eq!(span_len(Some((5, 5))), 1); + assert_eq!(span_len(Some((5, 9))), 5); + } +} diff --git a/crates/superbank-verify/src/chdb.rs b/crates/superbank-verify/src/chdb.rs new file mode 100644 index 0000000..b72e15c --- /dev/null +++ b/crates/superbank-verify/src/chdb.rs @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Read-only ClickHouse access. All range queries deduplicate +//! ReplacingMergeTree rows with `LIMIT 1 BY `; because the tables +//! use `ReplacingMergeTree(slot)` with the version column inside the sorting +//! key, duplicate rows for a key carry no deterministic winner — an explicit +//! `uniqExact` probe reports keys whose duplicates actually differ. + +use std::collections::BTreeMap; +use std::time::Instant; + +use anyhow::{Context, Result}; +use clickhouse::{Client as ClickHouseClient, Row}; +use serde::Deserialize; +use serde_big_array::Array; + +use crate::cli::Args; +use crate::metrics; +use crate::report::{Finding, FindingCode}; +use crate::verify::poh::Signature64; +use crate::verify::{BlockInfo, EntryInfo}; + +/// slot -> slot_idx -> all signatures of that transaction, in order. +pub(crate) type TxSignaturesBySlot = BTreeMap>>; + +#[derive(Clone)] +pub(crate) struct ChDb { + client: ClickHouseClient, + pub(crate) blocks_table: String, + pub(crate) entries_table: String, + pub(crate) transactions_table: String, +} + +#[derive(Debug, Row, Deserialize)] +struct BlockRow { + slot: u64, + parent_slot: u64, + blockhash: Array, + parent_blockhash: Array, + executed_transaction_count: u64, + entry_count: u64, +} + +impl From for BlockInfo { + fn from(row: BlockRow) -> Self { + BlockInfo { + slot: row.slot, + parent_slot: row.parent_slot, + blockhash: *row.blockhash, + parent_blockhash: *row.parent_blockhash, + executed_transaction_count: row.executed_transaction_count, + entry_count: row.entry_count, + } + } +} + +#[derive(Debug, Row, Deserialize)] +struct EntryRow { + slot: u64, + entry_index: u32, + starting_transaction_index: u32, + transaction_count: u32, + num_hashes: u64, + hash: Array, +} + +#[derive(Debug, Row, Deserialize)] +struct TxSignaturesRow { + slot: u64, + slot_idx: u32, + tx_signatures: Vec>, +} + +impl ChDb { + pub(crate) fn new(args: &Args) -> Self { + let mut client = ClickHouseClient::default() + .with_url(&args.clickhouse_url) + .with_database(&args.clickhouse_database); + if !args.clickhouse_user.is_empty() { + client = client.with_user(&args.clickhouse_user); + } + if !args.clickhouse_password.is_empty() { + client = client.with_password(&args.clickhouse_password); + } + Self { + client, + blocks_table: args.blocks_table.clone(), + entries_table: args.entries_table.clone(), + transactions_table: args.transactions_table.clone(), + } + } + + /// Lowest and highest slot present in blocks_metadata. + pub(crate) async fn fetch_bounds(&self) -> Result> { + #[derive(Debug, Row, Deserialize)] + struct BoundsRow { + min_slot: Option, + max_slot: Option, + } + + let query = format!( + "SELECT minOrNull(slot) AS min_slot, maxOrNull(slot) AS max_slot FROM {}", + self.blocks_table + ); + let row = self + .client + .query(&query) + .fetch_one::() + .await + .with_context(|| format!("query slot bounds from {}", self.blocks_table))?; + Ok(row.min_slot.zip(row.max_slot)) + } + + pub(crate) async fn fetch_blocks(&self, start: u64, end: u64) -> Result> { + let query = format!( + "SELECT slot, parent_slot, blockhash, parent_blockhash, \ + executed_transaction_count, entry_count \ + FROM {} WHERE slot BETWEEN {start} AND {end} \ + ORDER BY slot LIMIT 1 BY slot", + self.blocks_table + ); + let started = Instant::now(); + let rows = self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("query blocks in [{start}, {end}]"))?; + metrics::observe_fetch_duration(&self.blocks_table, started.elapsed().as_secs_f64()); + Ok(rows.into_iter().map(BlockInfo::from).collect()) + } + + pub(crate) async fn fetch_block_at(&self, slot: u64) -> Result> { + let query = format!( + "SELECT slot, parent_slot, blockhash, parent_blockhash, \ + executed_transaction_count, entry_count \ + FROM {} WHERE slot = {slot} ORDER BY slot LIMIT 1 BY slot", + self.blocks_table + ); + let row = self + .client + .query(&query) + .fetch_optional::() + .await + .with_context(|| format!("query block at slot {slot}"))?; + Ok(row.map(BlockInfo::from)) + } + + pub(crate) async fn fetch_first_block_after(&self, slot: u64) -> Result> { + let query = format!( + "SELECT slot, parent_slot, blockhash, parent_blockhash, \ + executed_transaction_count, entry_count \ + FROM {} WHERE slot > {slot} ORDER BY slot LIMIT 1 BY slot LIMIT 1", + self.blocks_table + ); + let row = self + .client + .query(&query) + .fetch_optional::() + .await + .with_context(|| format!("query first block after slot {slot}"))?; + Ok(row.map(BlockInfo::from)) + } + + pub(crate) async fn fetch_entries( + &self, + start: u64, + end: u64, + ) -> Result>> { + let query = format!( + "SELECT slot, entry_index, starting_transaction_index, \ + transaction_count, num_hashes, hash \ + FROM {} WHERE slot BETWEEN {start} AND {end} \ + ORDER BY slot, entry_index LIMIT 1 BY slot, entry_index", + self.entries_table + ); + let started = Instant::now(); + let rows = self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("query entries in [{start}, {end}]"))?; + metrics::observe_fetch_duration(&self.entries_table, started.elapsed().as_secs_f64()); + + let mut by_slot: BTreeMap> = BTreeMap::new(); + for row in rows { + by_slot.entry(row.slot).or_default().push(EntryInfo { + entry_index: row.entry_index, + num_hashes: row.num_hashes, + hash: *row.hash, + starting_transaction_index: row.starting_transaction_index, + transaction_count: row.transaction_count, + }); + } + Ok(by_slot) + } + + pub(crate) async fn fetch_tx_signatures( + &self, + start: u64, + end: u64, + ) -> Result { + let query = format!( + "SELECT slot, slot_idx, tx_signatures \ + FROM {} WHERE slot BETWEEN {start} AND {end} \ + ORDER BY slot, slot_idx LIMIT 1 BY slot, slot_idx", + self.transactions_table + ); + let started = Instant::now(); + let rows = self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("query transaction signatures in [{start}, {end}]"))?; + metrics::observe_fetch_duration(&self.transactions_table, started.elapsed().as_secs_f64()); + + let mut by_slot: TxSignaturesBySlot = BTreeMap::new(); + for row in rows { + by_slot.entry(row.slot).or_default().insert( + row.slot_idx, + row.tx_signatures + .into_iter() + .map(|signature| *signature) + .collect(), + ); + } + Ok(by_slot) + } + + /// 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( + &self, + start: u64, + end: u64, + include_transactions: bool, + ) -> Result> { + #[derive(Debug, Row, Deserialize)] + struct SlotConflict { + slot: u64, + } + + #[derive(Debug, Row, Deserialize)] + struct SlotIndexConflict { + slot: u64, + index: u32, + } + + let mut findings = Vec::new(); + + let query = format!( + "SELECT slot FROM {} WHERE slot BETWEEN {start} AND {end} GROUP BY slot \ + HAVING uniqExact((parent_slot, blockhash, parent_blockhash, \ + executed_transaction_count, entry_count)) > 1", + self.blocks_table + ); + for row in self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("probe duplicate blocks in [{start}, {end}]"))? + { + findings.push(Finding::new( + row.slot, + FindingCode::DuplicateConflict, + format!("conflicting duplicate rows in {}", self.blocks_table), + )); + } + + let query = format!( + "SELECT slot, entry_index AS index FROM {} \ + WHERE slot BETWEEN {start} AND {end} GROUP BY slot, entry_index \ + HAVING uniqExact((starting_transaction_index, transaction_count, \ + num_hashes, hash)) > 1", + self.entries_table + ); + for row in self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("probe duplicate entries in [{start}, {end}]"))? + { + findings.push( + Finding::new( + row.slot, + FindingCode::DuplicateConflict, + format!("conflicting duplicate rows in {}", self.entries_table), + ) + .with_entry_index(row.index), + ); + } + + if include_transactions { + let query = format!( + "SELECT slot, slot_idx AS index FROM {} \ + WHERE slot BETWEEN {start} AND {end} GROUP BY slot, slot_idx \ + HAVING uniqExact((signature, tx_signatures)) > 1", + self.transactions_table + ); + for row in self + .client + .query(&query) + .fetch_all::() + .await + .with_context(|| format!("probe duplicate transactions in [{start}, {end}]"))? + { + findings.push(Finding::new( + row.slot, + FindingCode::DuplicateConflict, + format!( + "conflicting duplicate rows in {} at slot_idx {}", + self.transactions_table, row.index + ), + )); + } + } + + Ok(findings) + } +} diff --git a/crates/superbank-verify/src/checkpoint.rs b/crates/superbank-verify/src/checkpoint.rs new file mode 100644 index 0000000..969e4f9 --- /dev/null +++ b/crates/superbank-verify/src/checkpoint.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Local resume state for long verification runs. Windows complete strictly +//! in ascending slot order, so a single high-water mark (`next_start`) plus +//! the accumulated counters captures the full run state. The file is written +//! atomically (temp file + rename) after every window. + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::report::RunCounters; + +/// Identifies a verification job; a checkpoint only resumes a job with an +/// identical descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct JobDescriptor { + pub(crate) range_start: u64, + pub(crate) range_end: u64, + pub(crate) mode: String, + pub(crate) blocks_table: String, + pub(crate) entries_table: String, + pub(crate) transactions_table: String, + pub(crate) ticks_per_slot: u64, + pub(crate) hashes_per_tick_schedule: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Checkpoint { + pub(crate) descriptor: JobDescriptor, + /// All slots strictly below this are fully accounted in `counters`. + pub(crate) next_start: u64, + pub(crate) counters: RunCounters, + pub(crate) updated_unix: u64, +} + +pub(crate) fn save(path: &Path, checkpoint: &Checkpoint) -> Result<()> { + let payload = serde_json::to_vec_pretty(checkpoint).context("serialize checkpoint")?; + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, payload) + .with_context(|| format!("write checkpoint temp file {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("rename checkpoint into place at {}", path.display()))?; + Ok(()) +} + +pub(crate) fn load(path: &Path) -> Result> { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err).with_context(|| format!("read checkpoint {}", path.display())); + } + }; + let checkpoint = serde_json::from_str::(&contents) + .with_context(|| format!("parse checkpoint {}", path.display()))?; + Ok(Some(checkpoint)) +} + +/// Load a checkpoint for resuming: the descriptor must match exactly. +pub(crate) fn load_for_resume( + path: &Path, + descriptor: &JobDescriptor, +) -> Result> { + let Some(checkpoint) = load(path)? else { + return Ok(None); + }; + if checkpoint.descriptor != *descriptor { + bail!( + "checkpoint {} was written by a different job (stored: {:?}, current: {:?}); \ + delete the file or use a different --checkpoint-file to start fresh", + path.display(), + checkpoint.descriptor, + descriptor + ); + } + Ok(Some(checkpoint)) +} + +pub(crate) fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn descriptor() -> JobDescriptor { + JobDescriptor { + range_start: 0, + range_end: 1000, + mode: "full".to_string(), + blocks_table: "default.blocks_metadata".to_string(), + entries_table: "default.entries".to_string(), + transactions_table: "default.transactions".to_string(), + ticks_per_slot: 64, + hashes_per_tick_schedule: "0:12500".to_string(), + } + } + + #[test] + fn round_trips_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("checkpoint.json"); + + assert!(load(&path).unwrap().is_none()); + + let checkpoint = Checkpoint { + descriptor: descriptor(), + next_start: 512, + counters: RunCounters { + slots_ok: 42, + ..RunCounters::default() + }, + updated_unix: now_unix(), + }; + save(&path, &checkpoint).unwrap(); + + let loaded = load_for_resume(&path, &descriptor()).unwrap().unwrap(); + assert_eq!(loaded.next_start, 512); + assert_eq!(loaded.counters.slots_ok, 42); + } + + #[test] + fn resume_rejects_descriptor_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("checkpoint.json"); + let checkpoint = Checkpoint { + descriptor: descriptor(), + next_start: 512, + counters: RunCounters::default(), + updated_unix: now_unix(), + }; + save(&path, &checkpoint).unwrap(); + + let mut other = descriptor(); + other.mode = "structural".to_string(); + assert!(load_for_resume(&path, &other).is_err()); + } +} diff --git a/crates/superbank-verify/src/cli.rs b/crates/superbank-verify/src/cli.rs new file mode 100644 index 0000000..8d1169e --- /dev/null +++ b/crates/superbank-verify/src/cli.rs @@ -0,0 +1,783 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Configuration resolution: CLI flags > environment variables > YAML config +//! file > defaults, following the same three-layer merge as the ingestor's +//! `cli.rs`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::parser::ValueSource; +use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser}; +use serde::{Deserialize, Serialize}; + +use crate::eras::HashesPerTickSchedule; +use crate::range::{RangeSpec, parse_range_spec}; +use crate::verify::VerifyMode; +use crate::verify::poh::Hash32; + +pub(crate) const MAINNET_GENESIS_HASH: &str = "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum ModeArg { + Structural, + Full, +} + +impl ModeArg { + pub(crate) fn as_str(self) -> &'static str { + match self { + ModeArg::Structural => "structural", + ModeArg::Full => "full", + } + } + + pub(crate) fn to_verify_mode(self) -> VerifyMode { + match self { + ModeArg::Structural => VerifyMode::Structural, + ModeArg::Full => VerifyMode::Full, + } + } +} + +#[derive(Parser, Debug)] +#[command(about = "Superbank Proof-of-History validator")] +struct CliArgs { + /// Path to YAML config file + #[arg(long, env = "SUPERBANK_VERIFY_CONFIG", value_name = "PATH")] + config: Option, + + /// Range to verify: ":" slots, "-" epochs, or "" one epoch + #[arg(long, env = "SUPERBANK_VERIFY_RANGE")] + range: Option, + + /// Verify everything present in blocks_metadata (genesis-to-tip) + #[arg( + long, + env = "SUPERBANK_VERIFY_FULL", + default_value_t = false, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + action = clap::ArgAction::Set + )] + full: bool, + + /// Verification mode: structural (no hash recomputation) | full (recompute every hash) + #[arg( + long, + env = "SUPERBANK_VERIFY_MODE", + value_enum, + default_value = "structural" + )] + mode: ModeArg, + + /// Slots per normal epoch (used to resolve epoch ranges) + #[arg( + long, + env = "SUPERBANK_VERIFY_SLOTS_PER_EPOCH", + default_value_t = 432_000 + )] + slots_per_epoch: u64, + + /// Whether the cluster uses warmup epochs (mainnet-beta: true) + #[arg( + long, + env = "SUPERBANK_VERIFY_EPOCH_WARMUP", + default_value_t = true, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + action = clap::ArgAction::Set + )] + epoch_warmup: bool, + + /// Ticks per slot (64 for all of mainnet history) + #[arg(long, env = "SUPERBANK_VERIFY_TICKS_PER_SLOT", default_value_t = 64)] + ticks_per_slot: u64, + + /// Expected genesis blockhash, checked against slot 0's parent_blockhash + /// (base58; empty string disables the check) + #[arg( + long, + env = "SUPERBANK_VERIFY_EXPECTED_GENESIS_HASH", + default_value = MAINNET_GENESIS_HASH + )] + expected_genesis_hash: String, + + /// hashes_per_tick eras as ":,..."; 0 disables the + /// tick-hash-count check for that era (default: built-in mainnet history) + #[arg(long, env = "SUPERBANK_VERIFY_HASHES_PER_TICK_SCHEDULE")] + hashes_per_tick_schedule: Option, + + /// Slots fetched and verified per window + #[arg(long, env = "SUPERBANK_VERIFY_WINDOW_SLOTS", default_value_t = 256)] + window_slots: u64, + + /// Windows prefetched ahead of verification + #[arg(long, env = "SUPERBANK_VERIFY_FETCH_AHEAD", default_value_t = 3)] + fetch_ahead: usize, + + /// Worker threads for hash recomputation (0 = all available cores) + #[arg(long, env = "SUPERBANK_VERIFY_VERIFY_THREADS", default_value_t = 0)] + verify_threads: usize, + + /// Checkpoint file for resumable runs + #[arg(long, env = "SUPERBANK_VERIFY_CHECKPOINT_FILE", value_name = "PATH")] + checkpoint_file: Option, + + /// Resume from the checkpoint file instead of starting over + #[arg( + long, + env = "SUPERBANK_VERIFY_RESUME", + default_value_t = false, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + action = clap::ArgAction::Set + )] + resume: bool, + + /// Write findings as JSONL to this file + #[arg(long, env = "SUPERBANK_VERIFY_REPORT_FILE", value_name = "PATH")] + report_file: Option, + + /// Abort after this many failed slots (0 = unlimited) + #[arg(long, env = "SUPERBANK_VERIFY_MAX_FAILURES", default_value_t = 0)] + max_failures: u64, + + /// Exit 0 even when unverifiable or missing slots remain + #[arg( + long, + env = "SUPERBANK_VERIFY_ALLOW_UNVERIFIABLE", + default_value_t = false, + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + action = clap::ArgAction::Set + )] + allow_unverifiable: bool, + + /// External trust anchor ":" (repeatable / + /// comma-separated) checked against the recorded blockhash + #[arg( + long = "anchor", + env = "SUPERBANK_VERIFY_ANCHOR", + value_delimiter = ',' + )] + anchor: Vec, + + /// ClickHouse HTTP URL + #[arg(long, env = "CLICKHOUSE_URL", default_value = "http://localhost:8123")] + clickhouse_url: String, + + /// ClickHouse database + #[arg(long, env = "CLICKHOUSE_DATABASE", default_value = "default")] + clickhouse_database: String, + + /// ClickHouse user + #[arg(long, env = "CLICKHOUSE_USER", default_value = "default")] + clickhouse_user: String, + + /// ClickHouse password + #[arg(long, env = "CLICKHOUSE_PASSWORD", default_value = "")] + clickhouse_password: String, + + /// Blocks metadata table + #[arg( + long, + env = "SUPERBANK_VERIFY_BLOCKS_TABLE", + default_value = "default.blocks_metadata" + )] + blocks_table: String, + + /// Entries table + #[arg( + long, + env = "SUPERBANK_VERIFY_ENTRIES_TABLE", + default_value = "default.entries" + )] + entries_table: String, + + /// Transactions table + #[arg( + long, + env = "SUPERBANK_VERIFY_TRANSACTIONS_TABLE", + default_value = "default.transactions" + )] + transactions_table: String, + + /// Metrics listen host + #[arg(long, env = "METRICS_HOST", default_value = "0.0.0.0")] + metrics_host: String, + + /// Metrics listen port + #[arg(long, env = "METRICS_PORT", default_value_t = 9902)] + metrics_port: u16, + + /// /health returns 503 when no window completed for this many seconds (0 disables) + #[arg( + long, + env = "SUPERBANK_VERIFY_HEALTH_STALE_SECS", + default_value_t = 300 + )] + health_stale_secs: u64, + + /// Optional cluster label added to all metrics + #[arg(long, env = "SUPERBANK_VERIFY_METRICS_CLUSTER_LABEL")] + metrics_cluster_label: Option, + + /// Emit a progress log line roughly every N slots + #[arg( + long, + env = "SUPERBANK_VERIFY_PROGRESS_EVERY_SLOTS", + default_value_t = 10_000 + )] + progress_every_slots: u64, + + /// Export one slot's block/entries/signatures as a JSON test fixture and exit + #[arg( + long, + env = "SUPERBANK_VERIFY_EXPORT_FIXTURE_SLOT", + value_name = "SLOT" + )] + export_fixture_slot: Option, + + /// Output path for --export-fixture-slot + #[arg(long, env = "SUPERBANK_VERIFY_EXPORT_FIXTURE_OUT", value_name = "PATH")] + export_fixture_out: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct FileConfig { + #[serde(alias = "range")] + range: Option, + #[serde(rename = "full", alias = "full")] + full: Option, + #[serde(alias = "mode")] + mode: Option, + #[serde(rename = "slots-per-epoch", alias = "slots_per_epoch")] + slots_per_epoch: Option, + #[serde(rename = "epoch-warmup", alias = "epoch_warmup")] + epoch_warmup: Option, + #[serde(rename = "ticks-per-slot", alias = "ticks_per_slot")] + ticks_per_slot: Option, + #[serde(rename = "expected-genesis-hash", alias = "expected_genesis_hash")] + expected_genesis_hash: Option, + #[serde( + rename = "hashes-per-tick-schedule", + alias = "hashes_per_tick_schedule" + )] + hashes_per_tick_schedule: Option, + #[serde(rename = "window-slots", alias = "window_slots")] + window_slots: Option, + #[serde(rename = "fetch-ahead", alias = "fetch_ahead")] + fetch_ahead: Option, + #[serde(rename = "verify-threads", alias = "verify_threads")] + verify_threads: Option, + #[serde(rename = "checkpoint-file", alias = "checkpoint_file")] + checkpoint_file: Option, + #[serde(alias = "resume")] + resume: Option, + #[serde(rename = "report-file", alias = "report_file")] + report_file: Option, + #[serde(rename = "max-failures", alias = "max_failures")] + max_failures: Option, + #[serde(rename = "allow-unverifiable", alias = "allow_unverifiable")] + allow_unverifiable: Option, + #[serde(alias = "anchors", rename = "anchor")] + anchor: Option>, + #[serde(rename = "clickhouse-url", alias = "clickhouse_url")] + clickhouse_url: Option, + #[serde(rename = "clickhouse-database", alias = "clickhouse_database")] + clickhouse_database: Option, + #[serde(rename = "clickhouse-user", alias = "clickhouse_user")] + clickhouse_user: Option, + #[serde(rename = "clickhouse-password", alias = "clickhouse_password")] + clickhouse_password: Option, + #[serde(rename = "blocks-table", alias = "blocks_table")] + blocks_table: Option, + #[serde(rename = "entries-table", alias = "entries_table")] + entries_table: Option, + #[serde(rename = "transactions-table", alias = "transactions_table")] + transactions_table: Option, + #[serde(rename = "metrics-host", alias = "metrics_host")] + metrics_host: Option, + #[serde(rename = "metrics-port", alias = "metrics_port")] + metrics_port: Option, + #[serde(rename = "health-stale-secs", alias = "health_stale_secs")] + health_stale_secs: Option, + #[serde(rename = "metrics-cluster-label", alias = "metrics_cluster_label")] + metrics_cluster_label: Option, + #[serde(rename = "progress-every-slots", alias = "progress_every_slots")] + progress_every_slots: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct Args { + pub(crate) range: Option, + pub(crate) full: bool, + pub(crate) mode: ModeArg, + pub(crate) slots_per_epoch: u64, + pub(crate) epoch_warmup: bool, + pub(crate) ticks_per_slot: u64, + pub(crate) expected_genesis_hash: Option, + pub(crate) hashes_per_tick_schedule: HashesPerTickSchedule, + pub(crate) window_slots: u64, + pub(crate) fetch_ahead: usize, + pub(crate) verify_threads: usize, + pub(crate) checkpoint_file: Option, + pub(crate) resume: bool, + pub(crate) report_file: Option, + pub(crate) max_failures: u64, + pub(crate) allow_unverifiable: bool, + pub(crate) anchors: Vec<(u64, Hash32)>, + pub(crate) clickhouse_url: String, + pub(crate) clickhouse_database: String, + pub(crate) clickhouse_user: String, + pub(crate) clickhouse_password: String, + pub(crate) blocks_table: String, + pub(crate) entries_table: String, + pub(crate) transactions_table: String, + pub(crate) metrics_host: String, + pub(crate) metrics_port: u16, + pub(crate) health_stale_secs: u64, + pub(crate) metrics_cluster_label: Option, + pub(crate) progress_every_slots: u64, + pub(crate) export_fixture: Option<(u64, PathBuf)>, +} + +pub(crate) fn resolve_args() -> Result { + let matches = CliArgs::command().get_matches(); + let cli = CliArgs::from_arg_matches(&matches).context("parse CLI arguments")?; + resolve_from(&matches, cli) +} + +fn resolve_from(matches: &ArgMatches, cli: CliArgs) -> Result { + let config = load_config(cli.config.as_deref())?.unwrap_or_default(); + + // --range / --full form ONE logical selector: when either is set + // explicitly (CLI or env), the config file's pair is ignored entirely — + // otherwise a config with `full: true` could never be overridden by + // `--range` (and vice versa). + let selector_explicit = + !should_use_config(matches, "range") || !should_use_config(matches, "full"); + let (range_spec, full) = if selector_explicit { + (cli.range, cli.full) + } else { + (cli.range.or(config.range), config.full.unwrap_or(cli.full)) + }; + let range = range_spec + .as_deref() + .map(parse_range_spec) + .transpose() + .context("parse --range")?; + + let expected_genesis_hash = merge_value( + matches, + "expected_genesis_hash", + cli.expected_genesis_hash, + config.expected_genesis_hash, + ); + let expected_genesis_hash = match expected_genesis_hash.trim() { + "" => None, + value => Some(parse_hash32(value).context("parse --expected-genesis-hash")?), + }; + + let hashes_per_tick_schedule = match merge_option( + matches, + "hashes_per_tick_schedule", + cli.hashes_per_tick_schedule, + config.hashes_per_tick_schedule, + ) { + Some(spec) => { + HashesPerTickSchedule::parse(&spec).context("parse --hashes-per-tick-schedule")? + } + None => HashesPerTickSchedule::mainnet(), + }; + + let anchor_specs = if matches + .value_source("anchor") + .is_none_or(|source| matches!(source, ValueSource::DefaultValue)) + { + config.anchor.unwrap_or(cli.anchor) + } else { + cli.anchor + }; + let anchors = anchor_specs + .iter() + .map(|spec| parse_anchor(spec)) + .collect::>>()?; + + let export_fixture = match (cli.export_fixture_slot, cli.export_fixture_out) { + (Some(slot), Some(path)) => Some((slot, path)), + (Some(_), None) => bail!("--export-fixture-slot requires --export-fixture-out"), + (None, Some(_)) => bail!("--export-fixture-out requires --export-fixture-slot"), + (None, None) => None, + }; + + let args = Args { + range, + full, + mode: merge_value(matches, "mode", cli.mode, config.mode), + slots_per_epoch: merge_value( + matches, + "slots_per_epoch", + cli.slots_per_epoch, + config.slots_per_epoch, + ), + epoch_warmup: merge_value( + matches, + "epoch_warmup", + cli.epoch_warmup, + config.epoch_warmup, + ), + ticks_per_slot: merge_value( + matches, + "ticks_per_slot", + cli.ticks_per_slot, + config.ticks_per_slot, + ), + expected_genesis_hash, + hashes_per_tick_schedule, + window_slots: merge_value( + matches, + "window_slots", + cli.window_slots, + config.window_slots, + ), + fetch_ahead: merge_value(matches, "fetch_ahead", cli.fetch_ahead, config.fetch_ahead), + verify_threads: merge_value( + matches, + "verify_threads", + cli.verify_threads, + config.verify_threads, + ), + checkpoint_file: merge_option( + matches, + "checkpoint_file", + cli.checkpoint_file, + config.checkpoint_file, + ), + resume: merge_value(matches, "resume", cli.resume, config.resume), + report_file: merge_option(matches, "report_file", cli.report_file, config.report_file), + max_failures: merge_value( + matches, + "max_failures", + cli.max_failures, + config.max_failures, + ), + allow_unverifiable: merge_value( + matches, + "allow_unverifiable", + cli.allow_unverifiable, + config.allow_unverifiable, + ), + anchors, + clickhouse_url: merge_value( + matches, + "clickhouse_url", + cli.clickhouse_url, + config.clickhouse_url, + ), + clickhouse_database: merge_value( + matches, + "clickhouse_database", + cli.clickhouse_database, + config.clickhouse_database, + ), + clickhouse_user: merge_value( + matches, + "clickhouse_user", + cli.clickhouse_user, + config.clickhouse_user, + ), + clickhouse_password: merge_value( + matches, + "clickhouse_password", + cli.clickhouse_password, + config.clickhouse_password, + ), + blocks_table: merge_value( + matches, + "blocks_table", + cli.blocks_table, + config.blocks_table, + ), + entries_table: merge_value( + matches, + "entries_table", + cli.entries_table, + config.entries_table, + ), + transactions_table: merge_value( + matches, + "transactions_table", + cli.transactions_table, + config.transactions_table, + ), + metrics_host: merge_value( + matches, + "metrics_host", + cli.metrics_host, + config.metrics_host, + ), + metrics_port: merge_value( + matches, + "metrics_port", + cli.metrics_port, + config.metrics_port, + ), + health_stale_secs: merge_value( + matches, + "health_stale_secs", + cli.health_stale_secs, + config.health_stale_secs, + ), + metrics_cluster_label: merge_option( + matches, + "metrics_cluster_label", + cli.metrics_cluster_label, + config.metrics_cluster_label, + ), + progress_every_slots: merge_value( + matches, + "progress_every_slots", + cli.progress_every_slots, + config.progress_every_slots, + ), + export_fixture, + }; + + validate_args(&args)?; + Ok(args) +} + +fn validate_args(args: &Args) -> Result<()> { + if args.export_fixture.is_none() { + match (args.range.is_some(), args.full) { + (true, true) => bail!("--range and --full are mutually exclusive"), + (false, false) => bail!("one of --range or --full is required"), + _ => {} + } + } + if args.resume && args.checkpoint_file.is_none() { + bail!("--resume requires --checkpoint-file"); + } + if args.window_slots == 0 { + bail!("--window-slots must be at least 1"); + } + if args.fetch_ahead == 0 { + bail!("--fetch-ahead must be at least 1"); + } + if args.ticks_per_slot == 0 { + bail!("--ticks-per-slot must be at least 1"); + } + Ok(()) +} + +pub(crate) fn parse_hash32(value: &str) -> Result { + let bytes = bs58::decode(value.trim()) + .into_vec() + .map_err(|err| anyhow!("invalid base58 hash {value:?}: {err}"))?; + let hash: Hash32 = bytes.as_slice().try_into().map_err(|_| { + anyhow!( + "hash {value:?} must decode to 32 bytes, got {}", + bytes.len() + ) + })?; + Ok(hash) +} + +fn parse_anchor(spec: &str) -> Result<(u64, Hash32)> { + let (slot, hash) = spec + .split_once(':') + .ok_or_else(|| anyhow!("anchor must be :, got {spec:?}"))?; + let slot: u64 = slot + .trim() + .parse() + .map_err(|_| anyhow!("invalid anchor slot in {spec:?}"))?; + let hash = parse_hash32(hash).with_context(|| format!("parse anchor {spec:?}"))?; + Ok((slot, hash)) +} + +fn load_config(path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(None); + }; + + let contents = std::fs::read_to_string(path) + .with_context(|| format!("read config file {}", path.display()))?; + let config = serde_yaml::from_str::(&contents) + .with_context(|| format!("parse config file {}", path.display()))?; + Ok(Some(config)) +} + +fn should_use_config(matches: &ArgMatches, name: &str) -> bool { + matches + .value_source(name) + .is_none_or(|source| matches!(source, ValueSource::DefaultValue)) +} + +fn merge_value(matches: &ArgMatches, name: &str, cli: T, config: Option) -> T { + if should_use_config(matches, name) { + config.unwrap_or(cli) + } else { + cli + } +} + +fn merge_option( + matches: &ArgMatches, + name: &str, + cli: Option, + config: Option, +) -> Option { + if should_use_config(matches, name) { + cli.or(config) + } else { + cli + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolve(argv: &[&str]) -> Result { + let matches = CliArgs::command().get_matches_from(argv); + let cli = CliArgs::from_arg_matches(&matches).unwrap(); + resolve_from(&matches, cli) + } + + #[test] + fn requires_range_or_full() { + assert!(resolve(&["superbank-verify"]).is_err()); + assert!(resolve(&["superbank-verify", "--range", "1:2", "--full"]).is_err()); + assert!(resolve(&["superbank-verify", "--range", "1:2"]).is_ok()); + assert!(resolve(&["superbank-verify", "--full"]).is_ok()); + } + + #[test] + fn defaults_are_mainnet_shaped() { + let args = resolve(&["superbank-verify", "--full"]).unwrap(); + assert_eq!(args.mode, ModeArg::Structural); + assert_eq!(args.slots_per_epoch, 432_000); + assert!(args.epoch_warmup); + assert_eq!(args.ticks_per_slot, 64); + assert_eq!( + args.expected_genesis_hash, + Some(parse_hash32(MAINNET_GENESIS_HASH).unwrap()) + ); + assert_eq!( + args.hashes_per_tick_schedule, + HashesPerTickSchedule::mainnet() + ); + assert_eq!(args.metrics_port, 9902); + } + + #[test] + fn empty_genesis_hash_disables_check() { + let args = resolve(&["superbank-verify", "--full", "--expected-genesis-hash", ""]).unwrap(); + assert_eq!(args.expected_genesis_hash, None); + } + + #[test] + fn parses_anchors() { + let args = resolve(&[ + "superbank-verify", + "--full", + "--anchor", + &format!("5:{MAINNET_GENESIS_HASH}"), + "--anchor", + &format!("9:{MAINNET_GENESIS_HASH}"), + ]) + .unwrap(); + assert_eq!(args.anchors.len(), 2); + assert_eq!(args.anchors[0].0, 5); + assert!(resolve(&["superbank-verify", "--full", "--anchor", "nope"]).is_err()); + } + + #[test] + fn resume_requires_checkpoint_file() { + assert!(resolve(&["superbank-verify", "--full", "--resume"]).is_err()); + assert!( + resolve(&[ + "superbank-verify", + "--full", + "--resume", + "--checkpoint-file", + "/tmp/cp.json" + ]) + .is_ok() + ); + } + + #[test] + fn fixture_export_needs_both_flags() { + assert!(resolve(&["superbank-verify", "--export-fixture-slot", "5"]).is_err()); + let args = resolve(&[ + "superbank-verify", + "--export-fixture-slot", + "5", + "--export-fixture-out", + "/tmp/fixture.json", + ]) + .unwrap(); + assert_eq!(args.export_fixture.as_ref().unwrap().0, 5); + } + + #[test] + fn cli_range_overrides_config_full() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write(&path, "full: true\n").unwrap(); + let path = path.to_str().unwrap(); + + // Config alone selects --full. + let args = resolve(&["superbank-verify", "--config", path]).unwrap(); + assert!(args.full); + assert!(args.range.is_none()); + + // An explicit --range must win over the config's `full: true`. + let args = resolve(&["superbank-verify", "--config", path, "--range", "1:2"]).unwrap(); + assert!(!args.full); + assert!(args.range.is_some()); + } + + #[test] + fn cli_full_overrides_config_range() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + std::fs::write(&path, "range: \"5:9\"\n").unwrap(); + let path = path.to_str().unwrap(); + + let args = resolve(&["superbank-verify", "--config", path]).unwrap(); + assert!(args.range.is_some()); + + let args = resolve(&["superbank-verify", "--config", path, "--full"]).unwrap(); + assert!(args.full); + assert!(args.range.is_none()); + + // A config supplying both is still rejected. + std::fs::write(dir.path().join("both.yaml"), "range: \"5:9\"\nfull: true\n").unwrap(); + let both = dir.path().join("both.yaml"); + assert!(resolve(&["superbank-verify", "--config", both.to_str().unwrap()]).is_err()); + } + + #[test] + fn file_config_parses_kebab_and_snake_case() { + let config: FileConfig = serde_yaml::from_str( + "window-slots: 512\nticks_per_slot: 32\nmode: full\nanchor:\n - \"5:abc\"\n", + ) + .expect("parse config"); + assert_eq!(config.window_slots, Some(512)); + assert_eq!(config.ticks_per_slot, Some(32)); + assert_eq!(config.mode, Some(ModeArg::Full)); + } +} diff --git a/crates/superbank-verify/src/epoch.rs b/crates/superbank-verify/src/epoch.rs new file mode 100644 index 0000000..2f3a334 --- /dev/null +++ b/crates/superbank-verify/src/epoch.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Epoch <-> slot mapping. Mainnet-beta uses a warmup schedule: epochs 0..=13 +//! are 32, 64, ..., 262144 slots long and normal 432000-slot epochs begin at +//! epoch 14 / slot 524256. (`superbank-rpc`'s getEpochSchedule currently +//! ignores warmup; this crate deliberately does not share that code.) + +use solana_epoch_schedule::EpochSchedule; + +#[derive(Debug, Clone)] +pub(crate) struct EpochSlots { + schedule: EpochSchedule, +} + +impl EpochSlots { + pub(crate) fn new(slots_per_epoch: u64, warmup: bool) -> Self { + Self { + schedule: EpochSchedule::custom(slots_per_epoch, slots_per_epoch, warmup), + } + } + + pub(crate) fn first_slot_in_epoch(&self, epoch: u64) -> u64 { + self.schedule.get_first_slot_in_epoch(epoch) + } + + pub(crate) fn last_slot_in_epoch(&self, epoch: u64) -> u64 { + self.schedule.get_last_slot_in_epoch(epoch) + } + + #[cfg(test)] + fn epoch_of_slot(&self, slot: u64) -> u64 { + self.schedule.get_epoch(slot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mainnet_warmup_constants() { + let epochs = EpochSlots::new(432_000, true); + assert_eq!(epochs.schedule.first_normal_epoch, 14); + assert_eq!(epochs.schedule.first_normal_slot, 524_256); + // Epoch 0 is the 32-slot minimum epoch. + assert_eq!(epochs.first_slot_in_epoch(0), 0); + assert_eq!(epochs.last_slot_in_epoch(0), 31); + assert_eq!(epochs.first_slot_in_epoch(1), 32); + assert_eq!(epochs.last_slot_in_epoch(1), 95); + // First normal epoch. + assert_eq!(epochs.first_slot_in_epoch(14), 524_256); + assert_eq!(epochs.last_slot_in_epoch(14), 524_256 + 432_000 - 1); + // A modern epoch. + assert_eq!( + epochs.first_slot_in_epoch(700), + 524_256 + (700 - 14) * 432_000 + ); + assert_eq!(epochs.epoch_of_slot(0), 0); + assert_eq!(epochs.epoch_of_slot(31), 0); + assert_eq!(epochs.epoch_of_slot(32), 1); + assert_eq!(epochs.epoch_of_slot(524_255), 13); + assert_eq!(epochs.epoch_of_slot(524_256), 14); + } + + #[test] + fn no_warmup_is_uniform() { + let epochs = EpochSlots::new(432_000, false); + assert_eq!(epochs.first_slot_in_epoch(0), 0); + assert_eq!(epochs.first_slot_in_epoch(2), 864_000); + assert_eq!(epochs.epoch_of_slot(431_999), 0); + assert_eq!(epochs.epoch_of_slot(432_000), 1); + } +} diff --git a/crates/superbank-verify/src/eras.rs b/crates/superbank-verify/src/eras.rs new file mode 100644 index 0000000..99808d2 --- /dev/null +++ b/crates/superbank-verify/src/eras.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Historical `hashes_per_tick` values by slot. The value changed on +//! mainnet-beta through the `update_hashes_per_tick{2..6}` feature gates; +//! features apply on the first block of the activation epoch, so the exact +//! boundary is that feature account's `activated_at` slot (no block exists +//! earlier in that epoch). +//! +//! The built-in table below was verified against the live mainnet feature +//! accounts on 2026-07-15 (activated_at read from +//! `Feature111111111111111111111111111111111111`-owned accounts) with target +//! values from `solana-sdk` v1.18.26 `clock.rs` (`UPDATED_HASHES_PER_TICK2..6`): +//! +//! | feature | account | activated_at | +//! |-------------------------|----------------------------------------------|--------------| +//! | update_hashes_per_tick2 | EWme9uFqfy1ikK1jhJs8fM5hxWnK336QJpbscNtizkTU | 253584001 | +//! | update_hashes_per_tick3 | 8C8MCtsab5SsfammbzvYz65HHauuUYdbY2DZ4sznH6h5 | 255312004 | +//! | update_hashes_per_tick4 | 8We4E7DPwF2WfAN8tRTtWQNhi98B99Qpuj7JoZ3Aikgg | 255744008 | +//! | update_hashes_per_tick5 | BsKLKAn1WM4HVhPRDsjosmqSg2J8Tq5xP2s2daDS6Ni4 | 257040000 | +//! | update_hashes_per_tick6 | FKu1qYwLQSiehz644H6Si65U5ZQ2cp9GxsyFUfYcuADv | 257904000 | +//! +//! (`update_hashes_per_tick`, activated at 232848000, set the value to 12500 — +//! a no-op on mainnet — and is omitted.) + +use anyhow::{Result, anyhow}; + +const MAINNET_HASHES_PER_TICK: &[(u64, u64)] = &[ + (0, 12_500), + (253_584_001, 17_500), + (255_312_004, 27_500), + (255_744_008, 47_500), + (257_040_000, 57_500), + (257_904_000, 62_500), +]; + +/// Piecewise-constant `hashes_per_tick` schedule keyed by first effective +/// slot. A value of 0 means PoH hash-count enforcement is disabled for that +/// era (matching Agave's `hashes_per_tick == 0` semantics). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HashesPerTickSchedule { + eras: Vec<(u64, u64)>, +} + +impl HashesPerTickSchedule { + pub(crate) fn mainnet() -> Self { + Self { + eras: MAINNET_HASHES_PER_TICK.to_vec(), + } + } + + /// Parse a `from_slot:value,from_slot:value,...` override. The first era + /// must start at slot 0 and eras must be strictly ascending. + pub(crate) fn parse(spec: &str) -> Result { + let mut eras = Vec::new(); + for part in spec.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (from_slot, value) = part.split_once(':').ok_or_else(|| { + anyhow!("hashes-per-tick era must be :, got {part:?}") + })?; + let from_slot: u64 = from_slot + .trim() + .parse() + .map_err(|_| anyhow!("invalid era from_slot in {part:?}"))?; + let value: u64 = value + .trim() + .parse() + .map_err(|_| anyhow!("invalid era value in {part:?}"))?; + eras.push((from_slot, value)); + } + if eras.is_empty() { + return Err(anyhow!("hashes-per-tick schedule cannot be empty")); + } + if eras[0].0 != 0 { + return Err(anyhow!("hashes-per-tick schedule must start at slot 0")); + } + if !eras.is_sorted_by_key(|(from_slot, _)| *from_slot) + || eras.windows(2).any(|pair| pair[0].0 == pair[1].0) + { + return Err(anyhow!( + "hashes-per-tick schedule slots must be strictly ascending" + )); + } + Ok(Self { eras }) + } + + /// A canonical string form, used to fingerprint checkpoints. + pub(crate) fn to_spec(&self) -> String { + self.eras + .iter() + .map(|(from_slot, value)| format!("{from_slot}:{value}")) + .collect::>() + .join(",") + } + + pub(crate) fn value_at(&self, slot: u64) -> u64 { + match self + .eras + .partition_point(|(from_slot, _)| *from_slot <= slot) + { + 0 => 0, + index => self.eras[index - 1].1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mainnet_era_boundaries_are_exact() { + let schedule = HashesPerTickSchedule::mainnet(); + assert_eq!(schedule.value_at(0), 12_500); + assert_eq!(schedule.value_at(253_584_000), 12_500); + assert_eq!(schedule.value_at(253_584_001), 17_500); + assert_eq!(schedule.value_at(255_312_003), 17_500); + assert_eq!(schedule.value_at(255_312_004), 27_500); + assert_eq!(schedule.value_at(255_744_008), 47_500); + assert_eq!(schedule.value_at(257_039_999), 47_500); + assert_eq!(schedule.value_at(257_040_000), 57_500); + assert_eq!(schedule.value_at(257_904_000), 62_500); + assert_eq!(schedule.value_at(u64::MAX), 62_500); + } + + #[test] + fn parse_round_trips() { + let schedule = HashesPerTickSchedule::parse("0:12500,100:0,200:62500").unwrap(); + assert_eq!(schedule.value_at(50), 12_500); + assert_eq!(schedule.value_at(100), 0); + assert_eq!(schedule.value_at(400), 62_500); + assert_eq!(schedule.to_spec(), "0:12500,100:0,200:62500"); + assert_eq!( + HashesPerTickSchedule::parse(&HashesPerTickSchedule::mainnet().to_spec()).unwrap(), + HashesPerTickSchedule::mainnet() + ); + } + + #[test] + fn parse_rejects_bad_specs() { + assert!(HashesPerTickSchedule::parse("").is_err()); + assert!(HashesPerTickSchedule::parse("5:100").is_err()); + assert!(HashesPerTickSchedule::parse("0:100,50:1,50:2").is_err()); + assert!(HashesPerTickSchedule::parse("0:100,60:1,50:2").is_err()); + assert!(HashesPerTickSchedule::parse("0:abc").is_err()); + } +} diff --git a/crates/superbank-verify/src/fixture.rs b/crates/superbank-verify/src/fixture.rs new file mode 100644 index 0000000..c313129 --- /dev/null +++ b/crates/superbank-verify/src/fixture.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Export one slot's block metadata, entries, and per-transaction signatures +//! as a JSON fixture — used to capture real mainnet blocks as golden test +//! vectors for the verifier. + +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde_json::json; +use tracing::info; + +use crate::chdb::ChDb; +use crate::cli::Args; +use crate::verify::b58; + +pub(crate) async fn export_fixture(args: &Args, slot: u64, out: &Path) -> Result<()> { + let db = ChDb::new(args); + let Some(block) = db.fetch_block_at(slot).await? else { + bail!("slot {slot} not found in {}", db.blocks_table); + }; + let entries = db + .fetch_entries(slot, slot) + .await? + .remove(&slot) + .unwrap_or_default(); + let transactions = db + .fetch_tx_signatures(slot, slot) + .await? + .remove(&slot) + .unwrap_or_default(); + + let fixture = json!({ + "slot": block.slot, + "parent_slot": block.parent_slot, + "blockhash": b58(&block.blockhash), + "parent_blockhash": b58(&block.parent_blockhash), + "executed_transaction_count": block.executed_transaction_count, + "entry_count": block.entry_count, + "entries": entries.iter().map(|entry| json!({ + "entry_index": entry.entry_index, + "num_hashes": entry.num_hashes, + "hash": b58(&entry.hash), + "starting_transaction_index": entry.starting_transaction_index, + "transaction_count": entry.transaction_count, + })).collect::>(), + "transactions": transactions.iter().map(|(slot_idx, signatures)| json!({ + "slot_idx": slot_idx, + "signatures": signatures.iter().map(|sig| b58(sig)).collect::>(), + })).collect::>(), + }); + + std::fs::write(out, serde_json::to_vec_pretty(&fixture)?) + .with_context(|| format!("write fixture to {}", out.display()))?; + info!( + slot, + entries = entries.len(), + transactions = transactions.len(), + out = %out.display(), + "fixture exported" + ); + Ok(()) +} diff --git a/crates/superbank-verify/src/main.rs b/crates/superbank-verify/src/main.rs new file mode 100644 index 0000000..cba8b69 --- /dev/null +++ b/crates/superbank-verify/src/main.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +mod chain; +mod chdb; +mod checkpoint; +mod cli; +mod epoch; +mod eras; +mod fixture; +mod metrics; +mod range; +mod report; +mod runner; +mod verify; + +use axum::{Router, routing::get}; +use tokio::net::TcpListener; +use tracing::{error, info, warn}; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let args = match cli::resolve_args() { + Ok(args) => args, + Err(err) => { + eprintln!("superbank-verify: {err:#}"); + std::process::exit(report::EXIT_OPERATIONAL_ERROR); + } + }; + metrics::force_init(args.mode.as_str(), args.metrics_cluster_label.as_deref()); + let git_sha = option_env!("SUPERBANK_GIT_SHA") + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("unknown"); + info!( + name = env!("CARGO_PKG_NAME"), + version = env!("CARGO_PKG_VERSION"), + git_sha = git_sha, + "starting" + ); + + let exit_code = match run(&args).await { + Ok(code) => code, + Err(err) => { + error!(error = format!("{err:#}"), "superbank-verify aborted"); + report::EXIT_OPERATIONAL_ERROR + } + }; + std::process::exit(exit_code); +} + +async fn run(args: &cli::Args) -> anyhow::Result { + let metrics_addr = format!("{}:{}", args.metrics_host, args.metrics_port); + let metrics_listener = TcpListener::bind(&metrics_addr).await?; + info!( + "Verify metrics server listening on http://{}/metrics", + metrics_addr + ); + let health_stale_secs = args.health_stale_secs; + let metrics_server = tokio::spawn(async move { + let app = Router::new() + .route("/metrics", get(metrics::metrics_handler)) + .route( + "/health", + get(move || metrics::health_handler(health_stale_secs)), + ); + axum::serve(metrics_listener, app).await + }); + + let result = match &args.export_fixture { + Some((slot, out)) => fixture::export_fixture(args, *slot, out) + .await + .map(|()| report::EXIT_OK), + None => runner::run(args) + .await + .map(|counters| report::exit_code(&counters, args.allow_unverifiable)), + }; + + metrics_server.abort(); + if let Err(err) = metrics_server.await + && !err.is_cancelled() + { + warn!("metrics server task failed: {err}"); + } + + result +} diff --git a/crates/superbank-verify/src/metrics.rs b/crates/superbank-verify/src/metrics.rs new file mode 100644 index 0000000..ab40c83 --- /dev/null +++ b/crates/superbank-verify/src/metrics.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +use std::borrow::Cow; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::http::{StatusCode, header}; +use axum::response::IntoResponse; +use once_cell::sync::OnceCell; +use prometheus_client::encoding::text::encode; +use prometheus_client::metrics::counter::Counter; +use prometheus_client::metrics::family::Family; +use prometheus_client::metrics::gauge::Gauge; +use prometheus_client::metrics::histogram::Histogram; +use prometheus_client::registry::Registry; +use prometheus_client_derive_encode::EncodeLabelSet; +use tracing::warn; + +use crate::report::{FindingCode, SlotStatus}; + +const WINDOW_BUCKETS: [f64; 12] = [ + 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, +]; +const FETCH_BUCKETS: [f64; 11] = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +]; + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct ModeInfoLabels { + mode: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct StatusLabels { + status: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct CodeLabels { + code: String, +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct TableLabels { + table: String, +} + +pub(crate) struct Metrics { + registry: Registry, + mode_info: Family, + range_start_slot: Gauge, + range_end_slot: Gauge, + last_processed_slot: Gauge, + slots_processed_total: Family, + findings_total: Family, + entries_verified_total: Counter, + hashes_computed_total: Counter, + window_duration_seconds: Histogram, + fetch_duration_seconds: Family, + last_window_completed_timestamp_seconds: Gauge, +} + +impl Metrics { + fn new(cluster_label: Option<&str>) -> Self { + let mode_info = Family::default(); + let range_start_slot = Gauge::default(); + let range_end_slot = Gauge::default(); + let last_processed_slot = Gauge::default(); + let slots_processed_total = Family::default(); + let findings_total = Family::default(); + let entries_verified_total = Counter::default(); + let hashes_computed_total = Counter::default(); + let window_duration_seconds = Histogram::new(WINDOW_BUCKETS); + let fetch_duration_seconds = + Family::new_with_constructor((|| Histogram::new(FETCH_BUCKETS)) as fn() -> Histogram); + let last_window_completed_timestamp_seconds = Gauge::default(); + + let mut registry = Registry::with_prefix("superbank"); + let registry_for_metrics = match cluster_label + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) => registry + .sub_registry_with_label((Cow::Borrowed("cluster"), Cow::Owned(value.to_string()))), + None => &mut registry, + }; + registry_for_metrics.register( + "verify_mode_info", + "Static mode label for this superbank-verify process", + mode_info.clone(), + ); + registry_for_metrics.register( + "verify_range_start_slot", + "First slot of the requested verification range", + range_start_slot.clone(), + ); + registry_for_metrics.register( + "verify_range_end_slot", + "Last slot of the requested verification range", + range_end_slot.clone(), + ); + registry_for_metrics.register( + "verify_last_processed_slot", + "Highest slot whose verification window has completed", + last_processed_slot.clone(), + ); + registry_for_metrics.register( + "verify_slots_processed_total", + "Slots accounted by final status (ok, failed, unverifiable, skipped, missing)", + slots_processed_total.clone(), + ); + registry_for_metrics.register( + "verify_findings_total", + "Verification findings by code", + findings_total.clone(), + ); + registry_for_metrics.register( + "verify_entries_verified_total", + "PoH entries whose hash was recomputed and compared", + entries_verified_total.clone(), + ); + registry_for_metrics.register( + "verify_hashes_computed_total", + "SHA-256 hashes recomputed while verifying entries", + hashes_computed_total.clone(), + ); + registry_for_metrics.register( + "verify_window_duration_seconds", + "Wall-clock time to verify one slot window", + window_duration_seconds.clone(), + ); + registry_for_metrics.register( + "verify_clickhouse_fetch_duration_seconds", + "ClickHouse fetch latency by source table", + fetch_duration_seconds.clone(), + ); + registry_for_metrics.register( + "verify_last_window_completed_timestamp_seconds", + "Unix timestamp of the last completed verification window", + last_window_completed_timestamp_seconds.clone(), + ); + if let Err(err) = kubert_prometheus_process::register( + registry_for_metrics.sub_registry_with_prefix("process"), + ) { + warn!("Failed to register process collector: {err}"); + } + + Self { + registry, + mode_info, + range_start_slot, + range_end_slot, + last_processed_slot, + slots_processed_total, + findings_total, + entries_verified_total, + hashes_computed_total, + window_duration_seconds, + fetch_duration_seconds, + last_window_completed_timestamp_seconds, + } + } + + fn export(&self) -> Result, String> { + let mut buffer = String::new(); + encode(&mut buffer, &self.registry).map_err(|err| err.to_string())?; + Ok(buffer.into_bytes()) + } +} + +pub(crate) static METRICS: OnceCell = OnceCell::new(); + +fn metrics() -> &'static Metrics { + METRICS + .get() + .expect("verify metrics must be initialized before use") +} + +pub(crate) fn force_init(mode: &str, cluster_label: Option<&str>) { + let metrics = METRICS.get_or_init(|| Metrics::new(cluster_label)); + metrics + .mode_info + .get_or_create(&ModeInfoLabels { + mode: mode.to_string(), + }) + .set(1); +} + +pub(crate) fn set_range(start: u64, end: u64) { + metrics().range_start_slot.set(clamp_i64(start)); + metrics().range_end_slot.set(clamp_i64(end)); +} + +pub(crate) fn set_last_processed_slot(slot: u64) { + metrics().last_processed_slot.set(clamp_i64(slot)); +} + +pub(crate) fn observe_slot_status(status: SlotStatus, count: u64) { + if count > 0 { + metrics() + .slots_processed_total + .get_or_create(&StatusLabels { + status: status.as_str().to_string(), + }) + .inc_by(count); + } +} + +pub(crate) fn observe_finding(code: FindingCode) { + metrics() + .findings_total + .get_or_create(&CodeLabels { + code: code.as_str().to_string(), + }) + .inc(); +} + +pub(crate) fn observe_verified(entries: u64, hashes: u64) { + metrics().entries_verified_total.inc_by(entries); + metrics().hashes_computed_total.inc_by(hashes); +} + +pub(crate) fn observe_window_completed(duration_seconds: f64, end_slot: u64) { + metrics().window_duration_seconds.observe(duration_seconds); + metrics().last_processed_slot.set(clamp_i64(end_slot)); + metrics() + .last_window_completed_timestamp_seconds + .set(current_timestamp_seconds()); +} + +pub(crate) fn observe_fetch_duration(table: &str, elapsed_seconds: f64) { + metrics() + .fetch_duration_seconds + .get_or_create(&TableLabels { + table: table.to_string(), + }) + .observe(elapsed_seconds); +} + +pub(crate) async fn health_handler(stale_secs: u64) -> impl IntoResponse { + let last_window = metrics().last_window_completed_timestamp_seconds.get(); + let stale = stale_secs > 0 + && last_window > 0 + && current_timestamp_seconds().saturating_sub(last_window) > stale_secs as i64; + if stale { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::OK + } +} + +pub(crate) async fn metrics_handler() -> impl IntoResponse { + match metrics().export() { + Ok(buffer) => ( + StatusCode::OK, + [( + header::CONTENT_TYPE, + "application/openmetrics-text; version=1.0.0; charset=utf-8", + )], + buffer, + ) + .into_response(), + Err(err) => { + warn!("Failed to scrape verify metrics: {err}"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +fn current_timestamp_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| clamp_i64(duration.as_secs())) + .unwrap_or_default() +} + +fn clamp_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} diff --git a/crates/superbank-verify/src/range.rs b/crates/superbank-verify/src/range.rs new file mode 100644 index 0000000..b3f6bd0 --- /dev/null +++ b/crates/superbank-verify/src/range.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +use anyhow::{Result, anyhow}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RangeSpec { + Slots { start: u64, end: u64 }, + Epochs { start: u64, end: u64 }, +} + +pub(crate) fn parse_range_spec(value: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(anyhow!("range cannot be empty")); + } + + if normalized.contains(':') { + let parts: Vec<_> = normalized.split(':').collect(); + if parts.len() != 2 { + return Err(anyhow!("slot range must be formatted as :")); + } + let start = parse_part("slot start", parts[0])?; + let end = parse_part("slot end", parts[1])?; + if start > end { + return Err(anyhow!( + "slot range start {} must be less than or equal to end {}", + start, + end + )); + } + return Ok(RangeSpec::Slots { start, end }); + } + + if normalized.contains('-') { + let parts: Vec<_> = normalized.split('-').collect(); + if parts.len() != 2 { + return Err(anyhow!("epoch range must be formatted as -")); + } + let start = parse_part("epoch start", parts[0])?; + let end = parse_part("epoch end", parts[1])?; + if start > end { + return Err(anyhow!( + "epoch range start {} must be less than or equal to end {}", + start, + end + )); + } + return Ok(RangeSpec::Epochs { start, end }); + } + + let epoch = parse_part("epoch", normalized)?; + Ok(RangeSpec::Epochs { + start: epoch, + end: epoch, + }) +} + +fn parse_part(label: &str, value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(anyhow!("{label} cannot be empty")); + } + trimmed + .parse::() + .map_err(|_| anyhow!("{label} must be an unsigned integer")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_slot_range() { + assert_eq!( + parse_range_spec("123:456").unwrap(), + RangeSpec::Slots { + start: 123, + end: 456 + } + ); + } + + #[test] + fn parses_epoch_range() { + assert_eq!( + parse_range_spec("1-10").unwrap(), + RangeSpec::Epochs { start: 1, end: 10 } + ); + } + + #[test] + fn parses_single_epoch() { + assert_eq!( + parse_range_spec("5").unwrap(), + RangeSpec::Epochs { start: 5, end: 5 } + ); + } + + #[test] + fn rejects_empty() { + assert!(parse_range_spec("").is_err()); + } + + #[test] + fn rejects_invalid_slot_range() { + assert!(parse_range_spec("1:0").is_err()); + } +} diff --git a/crates/superbank-verify/src/report.rs b/crates/superbank-verify/src/report.rs new file mode 100644 index 0000000..c17a372 --- /dev/null +++ b/crates/superbank-verify/src/report.rs @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +use std::collections::BTreeMap; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Severity class of a finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FindingClass { + /// Verification failure: the data contradicts Proof-of-History. + Fail, + /// The data needed to verify is absent; nothing can be proven either way. + Unverifiable, + /// Suspicious but non-fatal observation. + Warn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FindingCode { + EntryHashMismatch, + BlockhashMismatch, + GenesisHashMismatch, + ChainBreak, + ParentSlotMismatch, + EntryCountMismatch, + EntryIndexGap, + TickCountMismatch, + TrailingEntry, + TickHashCountMismatch, + TxIndexMismatch, + DuplicateConflict, + AnchorMismatch, + NumHashesOutOfRange, + MissingEntries, + MissingTransactions, + MissingParent, + MissingBlock, + AnchorNotChecked, + ZeroNumHashesEntry, +} + +impl FindingCode { + pub(crate) fn class(self) -> FindingClass { + match self { + FindingCode::EntryHashMismatch + | FindingCode::BlockhashMismatch + | FindingCode::GenesisHashMismatch + | FindingCode::ChainBreak + | FindingCode::ParentSlotMismatch + | FindingCode::EntryCountMismatch + | FindingCode::EntryIndexGap + | FindingCode::TickCountMismatch + | FindingCode::TrailingEntry + | FindingCode::TickHashCountMismatch + | FindingCode::TxIndexMismatch + | FindingCode::DuplicateConflict + | FindingCode::AnchorMismatch + | FindingCode::NumHashesOutOfRange => FindingClass::Fail, + FindingCode::MissingEntries + | FindingCode::MissingTransactions + | FindingCode::MissingParent + | FindingCode::MissingBlock + | FindingCode::AnchorNotChecked => FindingClass::Unverifiable, + FindingCode::ZeroNumHashesEntry => FindingClass::Warn, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + FindingCode::EntryHashMismatch => "entry_hash_mismatch", + FindingCode::BlockhashMismatch => "blockhash_mismatch", + FindingCode::GenesisHashMismatch => "genesis_hash_mismatch", + FindingCode::ChainBreak => "chain_break", + FindingCode::ParentSlotMismatch => "parent_slot_mismatch", + FindingCode::EntryCountMismatch => "entry_count_mismatch", + FindingCode::EntryIndexGap => "entry_index_gap", + FindingCode::TickCountMismatch => "tick_count_mismatch", + FindingCode::TrailingEntry => "trailing_entry", + FindingCode::TickHashCountMismatch => "tick_hash_count_mismatch", + FindingCode::TxIndexMismatch => "tx_index_mismatch", + FindingCode::DuplicateConflict => "duplicate_conflict", + FindingCode::AnchorMismatch => "anchor_mismatch", + FindingCode::NumHashesOutOfRange => "num_hashes_out_of_range", + FindingCode::MissingEntries => "missing_entries", + FindingCode::MissingTransactions => "missing_transactions", + FindingCode::MissingParent => "missing_parent", + FindingCode::MissingBlock => "missing_block", + FindingCode::AnchorNotChecked => "anchor_not_checked", + FindingCode::ZeroNumHashesEntry => "zero_num_hashes_entry", + } + } +} + +/// A single verification finding, emitted as one JSONL line in the report file. +#[derive(Debug, Clone, Serialize)] +pub(crate) struct Finding { + pub(crate) slot: u64, + pub(crate) code: FindingCode, + pub(crate) class: FindingClass, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) entry_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) expected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) actual: Option, + pub(crate) detail: String, +} + +impl Finding { + pub(crate) fn new(slot: u64, code: FindingCode, detail: impl Into) -> Self { + Self { + slot, + code, + class: code.class(), + entry_index: None, + expected: None, + actual: None, + detail: detail.into(), + } + } + + pub(crate) fn with_entry_index(mut self, entry_index: u32) -> Self { + self.entry_index = Some(entry_index); + self + } + + pub(crate) fn with_expected_actual( + mut self, + expected: impl Into, + actual: impl Into, + ) -> Self { + self.expected = Some(expected.into()); + self.actual = Some(actual.into()); + self + } +} + +/// Final status of a slot inside the requested range. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SlotStatus { + /// Block present and every applicable check passed. + Ok, + /// At least one Fail-class finding. + Failed, + /// Block present but required data is missing (entries/transactions/parent). + Unverifiable, + /// The chain skipped this slot (covered by a later block's parent link). + Skipped, + /// Neither present in our data nor claimed skipped by the chain: a data gap. + Missing, +} + +impl SlotStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + SlotStatus::Ok => "ok", + SlotStatus::Failed => "failed", + SlotStatus::Unverifiable => "unverifiable", + SlotStatus::Skipped => "skipped", + SlotStatus::Missing => "missing", + } + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub(crate) struct RunCounters { + pub(crate) slots_ok: u64, + pub(crate) slots_failed: u64, + pub(crate) slots_unverifiable: u64, + pub(crate) slots_skipped: u64, + pub(crate) slots_missing: u64, + /// Fail-class findings not attributable to a counted slot (e.g. duplicate + /// conflicts on slots with no blocks_metadata row). + #[serde(default)] + pub(crate) orphan_failures: u64, + /// Anchors that were requested but never compared against a block. + #[serde(default)] + pub(crate) anchors_unchecked: u64, + pub(crate) entries_verified: u64, + pub(crate) hashes_computed: u64, + pub(crate) findings: BTreeMap, +} + +impl RunCounters { + pub(crate) fn record_slot(&mut self, status: SlotStatus) { + match status { + SlotStatus::Ok => self.slots_ok += 1, + SlotStatus::Failed => self.slots_failed += 1, + SlotStatus::Unverifiable => self.slots_unverifiable += 1, + SlotStatus::Skipped => self.slots_skipped += 1, + SlotStatus::Missing => self.slots_missing += 1, + } + } + + pub(crate) fn record_finding(&mut self, code: FindingCode) { + *self.findings.entry(code.as_str().to_string()).or_default() += 1; + } + + pub(crate) fn has_failures(&self) -> bool { + self.slots_failed > 0 || self.orphan_failures > 0 + } + + pub(crate) fn has_unverifiable(&self) -> bool { + self.slots_unverifiable > 0 || self.slots_missing > 0 || self.anchors_unchecked > 0 + } + + pub(crate) fn failure_count(&self) -> u64 { + self.slots_failed + self.orphan_failures + } +} + +/// Exit-code contract, documented in the crate README: +/// 0 = clean, 1 = operational error, 2 = verification failures, +/// 3 = no failures but unverifiable/missing slots (0 with --allow-unverifiable). +pub(crate) const EXIT_OK: i32 = 0; +pub(crate) const EXIT_OPERATIONAL_ERROR: i32 = 1; +pub(crate) const EXIT_VERIFICATION_FAILED: i32 = 2; +pub(crate) const EXIT_UNVERIFIABLE: i32 = 3; + +pub(crate) fn exit_code(counters: &RunCounters, allow_unverifiable: bool) -> i32 { + if counters.has_failures() { + EXIT_VERIFICATION_FAILED + } else if counters.has_unverifiable() && !allow_unverifiable { + EXIT_UNVERIFIABLE + } else { + EXIT_OK + } +} + +pub(crate) struct ReportWriter { + writer: Option>, +} + +impl ReportWriter { + pub(crate) fn new(path: Option<&Path>, append: bool) -> Result { + let writer = match path { + Some(path) => { + let file = OpenOptions::new() + .create(true) + .append(append) + .write(true) + .truncate(!append) + .open(path) + .with_context(|| format!("open report file {}", path.display()))?; + Some(BufWriter::new(file)) + } + None => None, + }; + Ok(Self { writer }) + } + + pub(crate) fn write_finding(&mut self, finding: &Finding) -> Result<()> { + if let Some(writer) = self.writer.as_mut() { + serde_json::to_writer(&mut *writer, finding).context("serialize finding")?; + writer.write_all(b"\n").context("write finding")?; + } + Ok(()) + } + + pub(crate) fn flush(&mut self) -> Result<()> { + if let Some(writer) = self.writer.as_mut() { + writer.flush().context("flush report file")?; + } + Ok(()) + } +} + +pub(crate) fn log_finding(finding: &Finding) { + match finding.class { + FindingClass::Fail => tracing::error!( + slot = finding.slot, + code = finding.code.as_str(), + entry_index = finding.entry_index, + expected = finding.expected.as_deref(), + actual = finding.actual.as_deref(), + detail = %finding.detail, + "verification failure" + ), + FindingClass::Unverifiable => tracing::warn!( + slot = finding.slot, + code = finding.code.as_str(), + entry_index = finding.entry_index, + detail = %finding.detail, + "unverifiable" + ), + FindingClass::Warn => tracing::warn!( + slot = finding.slot, + code = finding.code.as_str(), + entry_index = finding.entry_index, + detail = %finding.detail, + "suspicious data" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_follow_contract() { + let mut counters = RunCounters::default(); + assert_eq!(exit_code(&counters, false), EXIT_OK); + + counters.slots_unverifiable = 1; + assert_eq!(exit_code(&counters, false), EXIT_UNVERIFIABLE); + assert_eq!(exit_code(&counters, true), EXIT_OK); + + counters.slots_failed = 1; + assert_eq!(exit_code(&counters, false), EXIT_VERIFICATION_FAILED); + assert_eq!(exit_code(&counters, true), EXIT_VERIFICATION_FAILED); + } + + #[test] + fn finding_serializes_snake_case() { + let finding = Finding::new(5, FindingCode::EntryHashMismatch, "boom") + .with_entry_index(3) + .with_expected_actual("a", "b"); + let json = serde_json::to_value(&finding).unwrap(); + assert_eq!(json["code"], "entry_hash_mismatch"); + assert_eq!(json["class"], "fail"); + assert_eq!(json["entry_index"], 3); + } + + #[test] + fn missing_data_is_unverifiable_class() { + assert_eq!( + FindingCode::MissingEntries.class(), + FindingClass::Unverifiable + ); + assert_eq!(FindingCode::ChainBreak.class(), FindingClass::Fail); + assert_eq!(FindingCode::ZeroNumHashesEntry.class(), FindingClass::Warn); + } +} diff --git a/crates/superbank-verify/src/runner.rs b/crates/superbank-verify/src/runner.rs new file mode 100644 index 0000000..852bbe7 --- /dev/null +++ b/crates/superbank-verify/src/runner.rs @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Windowed fetch -> verify -> account pipeline. A fetcher task streams slot +//! windows from ClickHouse a few windows ahead; verification fans out across +//! a rayon pool (block-level parallelism — each block starts from its +//! recorded parent_blockhash); accounting, chain linkage, reporting, and +//! checkpointing stay sequential so windows complete strictly in order. + +use std::collections::{BTreeMap, HashMap}; +use std::time::Instant; + +use anyhow::{Context, Result, bail}; +use rayon::prelude::*; +use tracing::{info, warn}; + +use crate::chain::{ChainWalk, ObserveResult, span_len}; +use crate::chdb::{ChDb, TxSignaturesBySlot}; +use crate::checkpoint::{self, Checkpoint, JobDescriptor}; +use crate::cli::Args; +use crate::epoch::EpochSlots; +use crate::metrics; +use crate::range::RangeSpec; +use crate::report::{ + Finding, FindingClass, FindingCode, ReportWriter, RunCounters, SlotStatus, log_finding, +}; +use crate::verify::{BlockInfo, EntryInfo, VerifyMode, VerifyOutcome, verify_block}; + +struct WindowData { + end: u64, + blocks: Vec, + entries: BTreeMap>, + tx_signatures: TxSignaturesBySlot, + duplicate_findings: Vec, + /// Parent block of the first in-range block, fetched alongside the first + /// window that contains any block so the chain walk can check linkage + /// across the range start. + parent_seed: Option, +} + +pub(crate) async fn run(args: &Args) -> Result { + let db = ChDb::new(args); + let epochs = EpochSlots::new(args.slots_per_epoch, args.epoch_warmup); + let (range_start, range_end) = resolve_range(args, &db, &epochs).await?; + let mode = args.mode.to_verify_mode(); + info!( + range_start, + range_end, + mode = args.mode.as_str(), + "verification range resolved" + ); + metrics::set_range(range_start, range_end); + + if args.expected_genesis_hash.is_none() && range_start == 0 { + warn!( + "no expected genesis hash configured; slot 0 will anchor on its recorded \ + parent_blockhash without external verification" + ); + } + + let descriptor = JobDescriptor { + range_start, + range_end, + mode: args.mode.as_str().to_string(), + blocks_table: args.blocks_table.clone(), + entries_table: args.entries_table.clone(), + transactions_table: args.transactions_table.clone(), + ticks_per_slot: args.ticks_per_slot, + hashes_per_tick_schedule: args.hashes_per_tick_schedule.to_spec(), + }; + + let mut counters = RunCounters::default(); + let mut cursor = range_start; + let mut resumed = false; + if args.resume + && let Some(path) = args.checkpoint_file.as_deref() + && let Some(saved) = checkpoint::load_for_resume(path, &descriptor)? + { + cursor = saved.next_start; + counters = saved.counters; + resumed = true; + info!( + next_start = cursor, + slots_ok = counters.slots_ok, + slots_failed = counters.slots_failed, + "resuming from checkpoint" + ); + if cursor > 0 { + metrics::set_last_processed_slot(cursor - 1); + } + } + + if cursor > range_end { + info!("checkpoint already covers the requested range"); + log_summary(&counters); + return Ok(counters); + } + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(args.verify_threads) + .thread_name(|index| format!("verify-{index}")) + .build() + .context("build verification thread pool")?; + + let (sender, mut receiver) = tokio::sync::mpsc::channel::>(args.fetch_ahead); + let fetch_db = db.clone(); + let window_slots = args.window_slots; + let fetch_transactions = mode == VerifyMode::Full; + let fetch_cursor = cursor; + let fetcher = tokio::spawn(async move { + let mut start = fetch_cursor; + let mut need_parent_seed = true; + while start <= range_end { + let end = range_end.min(start.saturating_add(window_slots - 1)); + let window = + fetch_window(&fetch_db, start, end, fetch_transactions, need_parent_seed).await; + if let Ok(window) = &window + && !window.blocks.is_empty() + { + need_parent_seed = false; + } + let failed = window.is_err(); + if sender.send(window).await.is_err() || failed { + return; + } + start = end + 1; + } + }); + + let anchors: HashMap = args.anchors.iter().copied().collect(); + let mut walk = ChainWalk::new(cursor, args.expected_genesis_hash, anchors); + let mut report = ReportWriter::new(args.report_file.as_deref(), resumed)?; + let mut progress = ProgressTracker::new(cursor, range_end, args.progress_every_slots); + let mut aborted = false; + + 'windows: while let Some(window) = receiver.recv().await { + let window = window?; + let window_started = Instant::now(); + + if let Some(seed) = &window.parent_seed { + walk.seed(seed.slot, seed.blockhash); + } + + let empty_tx: BTreeMap> = BTreeMap::new(); + let ticks_per_slot = args.ticks_per_slot; + let schedule = &args.hashes_per_tick_schedule; + let outcomes: Vec = tokio::task::block_in_place(|| { + pool.install(|| { + window + .blocks + .par_iter() + .map(|block| { + let entries = window.entries.get(&block.slot); + match entries.filter(|entries| !entries.is_empty()) { + Some(entries) => verify_block( + mode, + block, + entries, + window.tx_signatures.get(&block.slot).unwrap_or(&empty_tx), + ticks_per_slot, + Some(schedule.value_at(block.slot)), + ), + None => VerifyOutcome { + findings: vec![Finding::new( + block.slot, + FindingCode::MissingEntries, + "block has no entries rows; PoH unverifiable \ + (range ingested without entry data?)", + )], + ..VerifyOutcome::default() + }, + } + }) + .collect() + }) + }); + + let mut duplicates_by_slot: BTreeMap> = BTreeMap::new(); + for finding in window.duplicate_findings { + duplicates_by_slot + .entry(finding.slot) + .or_default() + .push(finding); + } + + for (block, outcome) in window.blocks.iter().zip(outcomes) { + let chain_result = walk.observe_block(block); + account_spans(&chain_result, &mut counters); + + let mut findings = chain_result.findings; + findings.extend(outcome.findings); + if let Some(duplicates) = duplicates_by_slot.remove(&block.slot) { + findings.extend(duplicates); + } + + let status = slot_status(&findings); + counters.record_slot(status); + metrics::observe_slot_status(status, 1); + counters.entries_verified += outcome.entries_verified; + counters.hashes_computed += outcome.hashes_computed; + metrics::observe_verified(outcome.entries_verified, outcome.hashes_computed); + + for finding in &findings { + counters.record_finding(finding.code); + metrics::observe_finding(finding.code); + log_finding(finding); + report.write_finding(finding)?; + } + + if args.max_failures > 0 && counters.failure_count() >= args.max_failures { + tracing::error!( + failed_slots = counters.slots_failed, + orphan_failures = counters.orphan_failures, + max_failures = args.max_failures, + "aborting: failure budget exhausted" + ); + aborted = true; + } + } + + // Duplicate conflicts on slots without a blocks_metadata row in this + // window (entries/transactions orphans): they carry no slot status, + // but Fail-class findings must still drive the exit code. + for (_, findings) in duplicates_by_slot { + for finding in findings { + if finding.class == FindingClass::Fail { + counters.orphan_failures += 1; + } + counters.record_finding(finding.code); + metrics::observe_finding(finding.code); + log_finding(&finding); + report.write_finding(&finding)?; + } + } + + report.flush()?; + metrics::observe_window_completed(window_started.elapsed().as_secs_f64(), window.end); + if let Some(path) = args.checkpoint_file.as_deref() { + // Slots after the last present block are not yet classified — + // they await the next block's parent-link claim (or the tail + // pass) — so the resume cursor must not run past them. + let accounted_through = walk + .last_present() + .map(|slot| (slot + 1).max(cursor)) + .unwrap_or(cursor); + checkpoint::save( + path, + &Checkpoint { + descriptor: descriptor.clone(), + next_start: accounted_through, + counters: counters.clone(), + updated_unix: checkpoint::now_unix(), + }, + )?; + } + progress.observe(window.end, &counters); + + if aborted { + break 'windows; + } + } + drop(receiver); + fetcher.await.context("window fetcher panicked")?; + + if !aborted { + // Classify the tail of the range after the last present block using + // the first block beyond the range (its parent link claims which tail + // slots were skipped). + let next_block = db.fetch_first_block_after(range_end).await?; + let tail = walk.observe_tail(range_end, next_block.as_ref()); + account_spans(&tail, &mut counters); + for finding in &tail.findings { + counters.record_finding(finding.code); + metrics::observe_finding(finding.code); + log_finding(finding); + report.write_finding(finding)?; + } + + // An anchor that never met a block proves nothing: surface it rather + // than letting the operator believe it was compared. + for (slot, _) in walk.unchecked_anchors() { + let finding = Finding::new( + slot, + FindingCode::AnchorNotChecked, + "anchor was never compared: slot has no block in the verified range \ + (skipped, missing, or outside the range)", + ); + counters.anchors_unchecked += 1; + counters.record_finding(finding.code); + metrics::observe_finding(finding.code); + log_finding(&finding); + report.write_finding(&finding)?; + } + report.flush()?; + if let Some(path) = args.checkpoint_file.as_deref() { + checkpoint::save( + path, + &Checkpoint { + descriptor: descriptor.clone(), + next_start: range_end + 1, + counters: counters.clone(), + updated_unix: checkpoint::now_unix(), + }, + )?; + } + } + + log_summary(&counters); + Ok(counters) +} + +async fn fetch_window( + db: &ChDb, + start: u64, + end: u64, + fetch_transactions: bool, + need_parent_seed: bool, +) -> Result { + let blocks = db.fetch_blocks(start, end).await?; + let entries = db.fetch_entries(start, end).await?; + let tx_signatures = if fetch_transactions { + db.fetch_tx_signatures(start, end).await? + } else { + TxSignaturesBySlot::new() + }; + let duplicate_findings = db + .fetch_duplicate_conflicts(start, end, fetch_transactions) + .await?; + + let parent_seed = match blocks.first() { + Some(block) if need_parent_seed && block.slot != 0 && block.parent_slot < start => { + db.fetch_block_at(block.parent_slot).await? + } + _ => None, + }; + + Ok(WindowData { + end, + blocks, + entries, + tx_signatures, + duplicate_findings, + parent_seed, + }) +} + +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 { + bail!("no blocks found in {}", db.blocks_table); + }; + if min_slot > 0 { + warn!( + first_present_slot = min_slot, + "blocks_metadata does not reach back to genesis; verification anchors on the \ + recorded parent_blockhash at the first present slot" + ); + } + return Ok((min_slot, max_slot)); + } + + match args.range { + Some(RangeSpec::Slots { start, end }) => Ok((start, end)), + Some(RangeSpec::Epochs { start, end }) => Ok(( + epochs.first_slot_in_epoch(start), + epochs.last_slot_in_epoch(end), + )), + None => bail!("no range specified"), + } +} + +fn account_spans(result: &ObserveResult, counters: &mut RunCounters) { + let skipped = span_len(result.skipped_span); + if skipped > 0 { + counters.slots_skipped += skipped; + metrics::observe_slot_status(SlotStatus::Skipped, skipped); + } + let missing = span_len(result.missing_span); + if missing > 0 { + counters.slots_missing += missing; + metrics::observe_slot_status(SlotStatus::Missing, missing); + } +} + +fn slot_status(findings: &[Finding]) -> SlotStatus { + let mut status = SlotStatus::Ok; + for finding in findings { + match finding.class { + FindingClass::Fail => return SlotStatus::Failed, + FindingClass::Unverifiable => status = SlotStatus::Unverifiable, + FindingClass::Warn => {} + } + } + status +} + +fn log_summary(counters: &RunCounters) { + info!( + slots_ok = counters.slots_ok, + slots_failed = counters.slots_failed, + slots_unverifiable = counters.slots_unverifiable, + slots_skipped = counters.slots_skipped, + slots_missing = counters.slots_missing, + orphan_failures = counters.orphan_failures, + anchors_unchecked = counters.anchors_unchecked, + entries_verified = counters.entries_verified, + hashes_computed = counters.hashes_computed, + "verification finished" + ); + for (code, count) in &counters.findings { + info!(code = code.as_str(), count, "finding totals"); + } +} + +struct ProgressTracker { + started: Instant, + range_start: u64, + range_end: u64, + log_every: u64, + next_log_at: u64, +} + +impl ProgressTracker { + fn new(range_start: u64, range_end: u64, log_every: u64) -> Self { + Self { + started: Instant::now(), + range_start, + range_end, + log_every: log_every.max(1), + next_log_at: range_start.saturating_add(log_every.max(1)), + } + } + + fn observe(&mut self, processed_through: u64, counters: &RunCounters) { + if processed_through < self.next_log_at && processed_through < self.range_end { + return; + } + self.next_log_at = processed_through.saturating_add(self.log_every); + + let total = self.range_end - self.range_start + 1; + let processed = processed_through - self.range_start + 1; + let percent = processed as f64 / total as f64 * 100.0; + let elapsed = self.started.elapsed().as_secs_f64(); + let rate = processed as f64 / elapsed.max(f64::EPSILON); + let eta_seconds = ((total - processed) as f64 / rate.max(f64::EPSILON)) as u64; + info!( + processed, + total, + percent = format!("{percent:.2}"), + slots_per_second = format!("{rate:.0}"), + eta_seconds, + slots_ok = counters.slots_ok, + slots_failed = counters.slots_failed, + slots_unverifiable = counters.slots_unverifiable, + "verification progress" + ); + } +} diff --git a/crates/superbank-verify/src/verify/mod.rs b/crates/superbank-verify/src/verify/mod.rs new file mode 100644 index 0000000..13a0c6c --- /dev/null +++ b/crates/superbank-verify/src/verify/mod.rs @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +pub(crate) mod poh; +pub(crate) mod structural; + +use std::collections::BTreeMap; + +use crate::report::{Finding, FindingCode}; +use poh::{Hash32, Signature64, next_hash, signature_merkle_root}; + +/// Block metadata needed for verification (from `blocks_metadata`). +#[derive(Debug, Clone)] +pub(crate) struct BlockInfo { + pub(crate) slot: u64, + pub(crate) parent_slot: u64, + pub(crate) blockhash: Hash32, + pub(crate) parent_blockhash: Hash32, + pub(crate) executed_transaction_count: u64, + pub(crate) entry_count: u64, +} + +/// One PoH entry (from `entries`), sorted by `entry_index`. +#[derive(Debug, Clone)] +pub(crate) struct EntryInfo { + pub(crate) entry_index: u32, + pub(crate) num_hashes: u64, + pub(crate) hash: Hash32, + pub(crate) starting_transaction_index: u32, + pub(crate) transaction_count: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VerifyMode { + Structural, + Full, +} + +/// Hard ceiling on per-entry `num_hashes` before recomputation. The largest +/// legitimate value is the era's hashes_per_tick (62,500 on current mainnet); +/// anything near this bound is corrupt data, and hashing it would stall a +/// verify worker for an arbitrarily long time instead of producing a finding. +pub(crate) const MAX_ENTRY_NUM_HASHES: u64 = 10_000_000; + +#[derive(Debug, Default)] +pub(crate) struct VerifyOutcome { + pub(crate) findings: Vec, + pub(crate) entries_verified: u64, + pub(crate) hashes_computed: u64, +} + +pub(crate) fn b58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} + +/// Verify a single block. `tx_signatures` maps `slot_idx` (transaction index +/// within the block) to that transaction's complete signature list; it is only +/// consulted in full mode. Chain linkage against the parent block is checked +/// separately by the chain walk, which owns cross-block state. +pub(crate) fn verify_block( + mode: VerifyMode, + block: &BlockInfo, + entries: &[EntryInfo], + tx_signatures: &BTreeMap>, + ticks_per_slot: u64, + hashes_per_tick: Option, +) -> VerifyOutcome { + let mut outcome = VerifyOutcome { + findings: structural::check_structure(block, entries, ticks_per_slot, hashes_per_tick), + ..VerifyOutcome::default() + }; + + let index_reliable = !outcome + .findings + .iter() + .any(|finding| finding.code == FindingCode::EntryIndexGap); + + if mode == VerifyMode::Full && index_reliable { + verify_hash_chain(block, entries, tx_signatures, &mut outcome); + } + + outcome +} + +fn verify_hash_chain( + block: &BlockInfo, + entries: &[EntryInfo], + tx_signatures: &BTreeMap>, + outcome: &mut VerifyOutcome, +) { + let mut prev = block.parent_blockhash; + + for entry in entries { + if entry.num_hashes == 0 { + outcome.findings.push( + Finding::new( + block.slot, + FindingCode::ZeroNumHashesEntry, + "entry with num_hashes = 0; never produced by Agave's recorder", + ) + .with_entry_index(entry.entry_index), + ); + } + + if entry.num_hashes > MAX_ENTRY_NUM_HASHES { + // Corrupt row: recomputing would stall the worker for an + // arbitrary time. Fail the entry and re-anchor at the recorded + // hash so later entries stay checkable. + outcome.findings.push( + Finding::new( + block.slot, + FindingCode::NumHashesOutOfRange, + format!( + "entry claims {} hashes (ceiling {MAX_ENTRY_NUM_HASHES}); refusing to recompute", + entry.num_hashes + ), + ) + .with_entry_index(entry.entry_index), + ); + prev = entry.hash; + continue; + } + + match entry_mixin(block.slot, entry, tx_signatures) { + Err(finding) => { + // The mixin cannot be computed; re-anchor the chain at the + // recorded hash so later entries stay checkable. + outcome.findings.push(finding); + } + Ok(mixin) => { + let computed = next_hash(&prev, entry.num_hashes, mixin.as_ref()); + outcome.entries_verified += 1; + outcome.hashes_computed += entry.num_hashes; + if computed != entry.hash { + outcome.findings.push( + Finding::new( + block.slot, + FindingCode::EntryHashMismatch, + "recomputed PoH hash does not match recorded entry hash", + ) + .with_entry_index(entry.entry_index) + .with_expected_actual(b58(&entry.hash), b58(&computed)), + ); + } + } + } + + prev = entry.hash; + } + // The last entry hash == blockhash equality needs no hashing and is + // checked in structural::check_structure for both modes. + let _ = prev; +} + +fn entry_mixin( + slot: u64, + entry: &EntryInfo, + tx_signatures: &BTreeMap>, +) -> Result, Finding> { + if entry.transaction_count == 0 { + return Ok(None); + } + + let start = entry.starting_transaction_index; + let end = start.saturating_add(entry.transaction_count); + let mut signatures: Vec = Vec::new(); + for slot_idx in start..end { + match tx_signatures.get(&slot_idx) { + Some(sigs) => signatures.extend_from_slice(sigs), + None => { + return Err(Finding::new( + slot, + FindingCode::MissingTransactions, + format!( + "transaction at slot_idx {slot_idx} not found; mixin uncomputable for entry" + ), + ) + .with_entry_index(entry.entry_index)); + } + } + } + + Ok(Some(signature_merkle_root(&signatures))) +} + +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + use solana_entry::entry::Entry; + use solana_hash::Hash; + use solana_message::Message; + use solana_signature::Signature; + use solana_transaction::Transaction; + + /// Build a valid block from agave entries: `layout` gives the number of + /// transactions per entry (0 = tick); returns block info, entry rows, and + /// the slot_idx -> signatures map. + pub(crate) fn build_block( + slot: u64, + parent_slot: u64, + start_hash: [u8; 32], + layout: &[(u64, usize)], + ) -> (BlockInfo, Vec, BTreeMap>) { + let mut entries = Vec::new(); + let mut tx_signatures = BTreeMap::new(); + let mut prev = Hash::new_from_array(start_hash); + let mut next_tx_index = 0u32; + let mut seed = 1u8; + + for (index, (num_hashes, tx_count)) in layout.iter().enumerate() { + let mut txs = Vec::new(); + for _ in 0..*tx_count { + let mut sig_bytes = [0u8; 64]; + for (i, byte) in sig_bytes.iter_mut().enumerate() { + *byte = seed.wrapping_mul(37).wrapping_add(i as u8); + } + seed = seed.wrapping_add(1); + let signature = Signature::from(sig_bytes); + tx_signatures + .entry(next_tx_index + txs.len() as u32) + .or_insert_with(Vec::new) + .push(sig_bytes); + txs.push(Transaction { + signatures: vec![signature], + message: Message::default(), + }); + } + + let entry = Entry::new(&prev, *num_hashes, txs); + prev = entry.hash; + entries.push(EntryInfo { + entry_index: index as u32, + num_hashes: *num_hashes, + hash: entry.hash.to_bytes(), + starting_transaction_index: next_tx_index, + transaction_count: *tx_count as u32, + }); + next_tx_index += *tx_count as u32; + } + + let block = BlockInfo { + slot, + parent_slot, + blockhash: prev.to_bytes(), + parent_blockhash: start_hash, + executed_transaction_count: next_tx_index as u64, + entry_count: entries.len() as u64, + }; + + (block, entries, tx_signatures) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_support::build_block; + + /// A minimal valid single-slot block: 4 ticks of 25 hashes each with two + /// transaction entries interleaved, at hashes_per_tick = 25. + fn sample_layout() -> Vec<(u64, usize)> { + vec![(25, 0), (10, 2), (15, 0), (25, 0), (5, 1), (20, 0)] + } + + #[test] + fn valid_block_verifies_clean_in_full_mode() { + let (block, entries, sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, Some(25)); + assert!( + outcome.findings.is_empty(), + "unexpected findings: {:?}", + outcome.findings + ); + assert_eq!(outcome.entries_verified, 6); + } + + #[test] + fn corrupted_entry_hash_is_localized() { + let (block, mut entries, sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + entries[1].hash[0] ^= 0xff; + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, Some(25)); + // The corrupted recorded hash breaks exactly the two PoH links that + // reference it (entry 1's own hash and entry 2's starting point); + // entries further down re-anchor on recorded hashes and stay clean. + let mismatched: Vec<_> = outcome + .findings + .iter() + .filter(|finding| finding.code == FindingCode::EntryHashMismatch) + .map(|finding| finding.entry_index) + .collect(); + assert_eq!(mismatched, vec![Some(1), Some(2)]); + } + + #[test] + fn corrupted_blockhash_is_reported() { + let (mut block, entries, sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + block.blockhash[0] ^= 0xff; + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, Some(25)); + assert!( + outcome + .findings + .iter() + .any(|finding| finding.code == FindingCode::BlockhashMismatch) + ); + } + + #[test] + fn missing_transaction_marks_entry_unverifiable_but_chain_continues() { + let (block, entries, mut sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + sigs.remove(&0); + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, Some(25)); + assert!( + outcome + .findings + .iter() + .any(|finding| finding.code == FindingCode::MissingTransactions) + ); + // All other entries still verified. + assert_eq!(outcome.entries_verified, 5); + assert!( + !outcome + .findings + .iter() + .any(|finding| finding.code == FindingCode::EntryHashMismatch) + ); + } + + #[test] + fn oversized_num_hashes_fails_without_hashing() { + let (block, mut entries, sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + entries[2].num_hashes = u64::MAX; // corrupt row; must not be recomputed + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, None); + let capped: Vec<_> = outcome + .findings + .iter() + .filter(|finding| finding.code == FindingCode::NumHashesOutOfRange) + .collect(); + assert_eq!(capped.len(), 1); + assert_eq!(capped[0].entry_index, Some(2)); + // The chain re-anchors on the recorded hash: every other entry still + // verifies, and no hash-mismatch cascade follows. + assert_eq!(outcome.entries_verified, 5); + assert!( + !outcome + .findings + .iter() + .any(|finding| finding.code == FindingCode::EntryHashMismatch) + ); + } + + #[test] + fn structural_mode_computes_no_hashes() { + let (block, entries, sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + let outcome = verify_block(VerifyMode::Structural, &block, &entries, &sigs, 4, Some(25)); + assert!(outcome.findings.is_empty()); + assert_eq!(outcome.hashes_computed, 0); + } + + #[test] + fn wrong_mixin_signature_fails_entry_hash() { + let (block, entries, mut sigs) = build_block(9, 8, [3u8; 32], &sample_layout()); + sigs.get_mut(&0).unwrap()[0][0] ^= 0x01; + let outcome = verify_block(VerifyMode::Full, &block, &entries, &sigs, 4, Some(25)); + let mismatches: Vec<_> = outcome + .findings + .iter() + .filter(|finding| finding.code == FindingCode::EntryHashMismatch) + .collect(); + assert_eq!(mismatches.len(), 1); + assert_eq!(mismatches[0].entry_index, Some(1)); + } +} diff --git a/crates/superbank-verify/src/verify/poh.rs b/crates/superbank-verify/src/verify/poh.rs new file mode 100644 index 0000000..859424f --- /dev/null +++ b/crates/superbank-verify/src/verify/poh.rs @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Pure Proof-of-History math, ported from Agave's `solana-entry` +//! (`entry/src/entry.rs` `next_hash` / `hash_transactions`) and +//! `solana-merkle-tree`. Operates on raw byte arrays so it stays independent +//! of any particular agave crate version; differential tests against +//! `solana-entry` pin the semantics. + +use sha2::{Digest, Sha256}; + +pub(crate) type Hash32 = [u8; 32]; +pub(crate) type Signature64 = [u8; 64]; + +const MERKLE_LEAF_PREFIX: &[u8] = &[0x00]; +const MERKLE_INTERMEDIATE_PREFIX: &[u8] = &[0x01]; + +#[inline] +fn sha256_chain(hash: &Hash32) -> Hash32 { + let mut hasher = Sha256::new(); + hasher.update(hash); + hasher.finalize().into() +} + +#[inline] +fn sha256_mixin(hash: &Hash32, mixin: &Hash32) -> Hash32 { + let mut hasher = Sha256::new(); + hasher.update(hash); + hasher.update(mixin); + hasher.finalize().into() +} + +/// Compute the PoH hash of an entry, given the previous entry's hash (or the +/// parent block's blockhash for the first entry of a block). +/// +/// Matches Agave `next_hash`: `num_hashes == 0` with no transactions leaves +/// the hash unchanged; otherwise `num_hashes - 1` chained hashes followed by a +/// final tick hash (no transactions) or a record hash of `cur || mixin`. +pub(crate) fn next_hash(start: &Hash32, num_hashes: u64, mixin: Option<&Hash32>) -> Hash32 { + if num_hashes == 0 && mixin.is_none() { + return *start; + } + let mut hash = *start; + for _ in 1..num_hashes { + hash = sha256_chain(&hash); + } + match mixin { + None => sha256_chain(&hash), + Some(mixin) => sha256_mixin(&hash, mixin), + } +} + +/// Merkle root over all transaction signatures of an entry, matching Agave +/// `hash_transactions`: leaves are the 64-byte signatures flat-mapped across +/// the entry's transactions in order; leaf nodes are hashed with a `0x00` +/// prefix, intermediate nodes with `0x01`, and an odd node at any level is +/// paired with itself. An empty signature list yields the all-zero hash +/// (`Hash::default()` in Agave). +pub(crate) fn signature_merkle_root(signatures: &[Signature64]) -> Hash32 { + if signatures.is_empty() { + return [0u8; 32]; + } + + let mut level: Vec = signatures + .iter() + .map(|signature| { + let mut hasher = Sha256::new(); + hasher.update(MERKLE_LEAF_PREFIX); + hasher.update(signature); + hasher.finalize().into() + }) + .collect(); + + while level.len() > 1 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for pair in level.chunks(2) { + let left = &pair[0]; + let right = pair.get(1).unwrap_or(left); + let mut hasher = Sha256::new(); + hasher.update(MERKLE_INTERMEDIATE_PREFIX); + hasher.update(left); + hasher.update(right); + next.push(hasher.finalize().into()); + } + level = next; + } + + level[0] +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_entry::entry::{Entry, hash_transactions, next_hash as agave_next_hash}; + use solana_hash::Hash; + use solana_message::{Message, VersionedMessage}; + use solana_signature::Signature; + use solana_transaction::Transaction; + use solana_transaction::versioned::VersionedTransaction; + + fn tx_with_signatures(signatures: Vec) -> VersionedTransaction { + VersionedTransaction { + signatures, + message: VersionedMessage::Legacy(Message::default()), + } + } + + fn legacy_tx(signatures: Vec) -> Transaction { + Transaction { + signatures, + message: Message::default(), + } + } + + fn deterministic_signature(seed: u8) -> ([u8; 64], Signature) { + let mut bytes = [0u8; 64]; + for (i, byte) in bytes.iter_mut().enumerate() { + *byte = seed.wrapping_add(i as u8).wrapping_mul(31); + } + (bytes, Signature::from(bytes)) + } + + #[test] + fn merkle_root_matches_agave_for_all_small_sizes() { + for count in 0..=9usize { + let mut raw = Vec::new(); + let mut sigs = Vec::new(); + for seed in 0..count { + let (bytes, sig) = deterministic_signature(seed as u8 + 1); + raw.push(bytes); + sigs.push(sig); + } + let ours = signature_merkle_root(&raw); + let theirs = hash_transactions(&[tx_with_signatures(sigs)]); + assert_eq!( + ours, + theirs.to_bytes(), + "merkle root diverges for {count} signatures" + ); + } + } + + #[test] + fn merkle_root_flattens_multi_signature_transactions() { + let (raw_a, sig_a) = deterministic_signature(10); + let (raw_b, sig_b) = deterministic_signature(20); + let (raw_c, sig_c) = deterministic_signature(30); + let ours = signature_merkle_root(&[raw_a, raw_b, raw_c]); + let theirs = hash_transactions(&[ + tx_with_signatures(vec![sig_a, sig_b]), + tx_with_signatures(vec![sig_c]), + ]); + assert_eq!(ours, theirs.to_bytes()); + } + + #[test] + fn next_hash_matches_agave_for_ticks() { + let start = Hash::new_unique(); + for num_hashes in [0u64, 1, 2, 3, 100] { + let ours = next_hash(&start.to_bytes(), num_hashes, None); + let theirs = agave_next_hash(&start, num_hashes, &[]); + assert_eq!(ours, theirs.to_bytes(), "tick diverges at {num_hashes}"); + } + } + + #[test] + fn next_hash_matches_agave_with_transactions() { + let start = Hash::new_unique(); + let (raw_a, sig_a) = deterministic_signature(42); + let (raw_b, sig_b) = deterministic_signature(43); + let txs = vec![tx_with_signatures(vec![sig_a, sig_b])]; + for num_hashes in [0u64, 1, 2, 500] { + let mixin = signature_merkle_root(&[raw_a, raw_b]); + let ours = next_hash(&start.to_bytes(), num_hashes, Some(&mixin)); + let theirs = agave_next_hash(&start, num_hashes, &txs); + assert_eq!(ours, theirs.to_bytes(), "record diverges at {num_hashes}"); + } + } + + #[test] + fn entry_chain_matches_agave_entry_verify() { + // Build a small chain of entries with agave and verify our recomputed + // hashes agree entry by entry. + let start = Hash::new_unique(); + let (raw_a, sig_a) = deterministic_signature(7); + let (_raw_b, sig_b) = deterministic_signature(9); + + let tick = Entry::new(&start, 3, vec![]); + let record = Entry::new(&tick.hash, 5, vec![legacy_tx(vec![sig_a])]); + let multi = Entry::new(&record.hash, 4, vec![legacy_tx(vec![sig_b, sig_a])]); + + let computed_tick = next_hash(&start.to_bytes(), 3, None); + assert_eq!(computed_tick, tick.hash.to_bytes()); + + let mixin = signature_merkle_root(&[raw_a]); + let computed_record = next_hash(&tick.hash.to_bytes(), 5, Some(&mixin)); + assert_eq!(computed_record, record.hash.to_bytes()); + + let mixin_multi = signature_merkle_root(&[sig_b.into(), sig_a.into()]); + let computed_multi = next_hash(&record.hash.to_bytes(), 4, Some(&mixin_multi)); + assert_eq!(computed_multi, multi.hash.to_bytes()); + } + + #[test] + fn merkle_matches_solana_merkle_tree_directly() { + let items: Vec<[u8; 64]> = (1..=5u8) + .map(|seed| deterministic_signature(seed).0) + .collect(); + let refs: Vec<&[u8]> = items.iter().map(|item| item.as_slice()).collect(); + let tree = solana_merkle_tree::MerkleTree::new(&refs); + let root = tree.get_root().expect("root"); + assert_eq!(signature_merkle_root(&items), root.to_bytes()); + } + + #[test] + fn zero_hashes_without_mixin_is_identity() { + let start = [7u8; 32]; + assert_eq!(next_hash(&start, 0, None), start); + } +} diff --git a/crates/superbank-verify/src/verify/structural.rs b/crates/superbank-verify/src/verify/structural.rs new file mode 100644 index 0000000..ac6a914 --- /dev/null +++ b/crates/superbank-verify/src/verify/structural.rs @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +//! Structural PoH invariants that need no hash recomputation, mirroring the +//! checks Agave's `blockstore_processor::verify_ticks` performs during replay. + +use crate::report::{Finding, FindingCode}; +use crate::verify::{BlockInfo, EntryInfo, b58}; + +/// The number of ticks a block must carry: its own slot's ticks plus one full +/// set per skipped slot since the parent (`tick_height` must advance from +/// `(parent_slot + 1) * ticks_per_slot` to `(slot + 1) * ticks_per_slot`). +/// Slot 0 has no parent and carries exactly one slot of ticks. +pub(crate) fn expected_tick_count(slot: u64, parent_slot: u64, ticks_per_slot: u64) -> u64 { + if slot == 0 { + ticks_per_slot + } else { + slot.saturating_sub(parent_slot) + .saturating_mul(ticks_per_slot) + } +} + +pub(crate) fn check_structure( + block: &BlockInfo, + entries: &[EntryInfo], + ticks_per_slot: u64, + hashes_per_tick: Option, +) -> Vec { + let mut findings = Vec::new(); + let slot = block.slot; + + // entries table rows must be indexed 0..n-1 with no gaps; rows arrive + // sorted and deduplicated. + let mut index_reliable = true; + for (position, entry) in entries.iter().enumerate() { + if entry.entry_index != position as u32 { + findings.push( + Finding::new( + slot, + FindingCode::EntryIndexGap, + format!( + "entry_index {} at position {position}; expected contiguous indices from 0", + entry.entry_index + ), + ) + .with_entry_index(entry.entry_index), + ); + index_reliable = false; + break; + } + } + + if block.entry_count > 0 && entries.len() as u64 != block.entry_count { + findings.push( + Finding::new( + slot, + FindingCode::EntryCountMismatch, + "entries row count does not match blocks_metadata.entry_count", + ) + .with_expected_actual(block.entry_count.to_string(), entries.len().to_string()), + ); + } + + // Everything below assumes the entry sequence is the block's real, + // complete entry stream. + if !index_reliable { + return findings; + } + + let tick_count = entries + .iter() + .filter(|entry| entry.transaction_count == 0) + .count() as u64; + let expected_ticks = expected_tick_count(slot, block.parent_slot, ticks_per_slot); + if tick_count != expected_ticks { + findings.push( + Finding::new( + slot, + FindingCode::TickCountMismatch, + format!( + "block covers slots {}..={slot} and must carry {expected_ticks} ticks", + block.parent_slot.saturating_add(1) + ), + ) + .with_expected_actual(expected_ticks.to_string(), tick_count.to_string()), + ); + } + + if let Some(last) = entries.last() { + if last.transaction_count != 0 { + findings.push( + Finding::new( + slot, + FindingCode::TrailingEntry, + "last entry of a block must be a tick", + ) + .with_entry_index(last.entry_index), + ); + } + // A block's blockhash is by definition its last entry's hash; this + // needs no recomputation, so it is enforced in both modes. + if last.hash != block.blockhash { + findings.push( + Finding::new( + slot, + FindingCode::BlockhashMismatch, + "last entry hash does not match recorded blockhash", + ) + .with_entry_index(last.entry_index) + .with_expected_actual(b58(&block.blockhash), b58(&last.hash)), + ); + } + } + + // Replica of Agave `verify_tick_hash_count`: the running num_hashes sum + // between consecutive ticks must equal hashes_per_tick exactly, and no + // tick may claim zero hashes. Skipped when hashes_per_tick is unknown for + // the era or hashing is disabled (value 0). + if let Some(hashes_per_tick) = hashes_per_tick.filter(|value| *value > 0) { + let mut tick_hash_count = 0u64; + for entry in entries { + tick_hash_count = tick_hash_count.saturating_add(entry.num_hashes); + if entry.transaction_count == 0 { + if entry.num_hashes == 0 || tick_hash_count != hashes_per_tick { + findings.push( + Finding::new( + slot, + FindingCode::TickHashCountMismatch, + "hashes accumulated since the previous tick must equal hashes_per_tick", + ) + .with_entry_index(entry.entry_index) + .with_expected_actual( + hashes_per_tick.to_string(), + tick_hash_count.to_string(), + ), + ); + break; + } + tick_hash_count = 0; + } + } + } + + // Entry transaction ranges must exactly tile [0, executed_transaction_count). + let mut expected_next = 0u32; + let mut tiling_ok = true; + for entry in entries { + if entry.starting_transaction_index != expected_next { + findings.push( + Finding::new( + slot, + FindingCode::TxIndexMismatch, + "entry starting_transaction_index does not continue the previous entry's range", + ) + .with_entry_index(entry.entry_index) + .with_expected_actual( + expected_next.to_string(), + entry.starting_transaction_index.to_string(), + ), + ); + tiling_ok = false; + break; + } + expected_next = expected_next.saturating_add(entry.transaction_count); + } + if tiling_ok && u64::from(expected_next) != block.executed_transaction_count { + findings.push( + Finding::new( + slot, + FindingCode::TxIndexMismatch, + "entry transaction ranges do not sum to executed_transaction_count", + ) + .with_expected_actual( + block.executed_transaction_count.to_string(), + expected_next.to_string(), + ), + ); + } + + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::verify::test_support::build_block; + + fn layout() -> Vec<(u64, usize)> { + // 4 ticks at hashes_per_tick = 25, with tx entries stealing hashes + // from their enclosing tick window. + vec![(25, 0), (10, 2), (15, 0), (25, 0), (5, 1), (20, 0)] + } + + #[test] + fn clean_block_has_no_findings() { + let (block, entries, _) = build_block(9, 8, [1u8; 32], &layout()); + assert!(check_structure(&block, &entries, 4, Some(25)).is_empty()); + } + + #[test] + fn skipped_slots_require_extra_ticks() { + // Parent is 5, slot is 9: 4 slots of ticks expected at 4 ticks/slot. + let (block, entries, _) = build_block(9, 5, [1u8; 32], &layout()); + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::TickCountMismatch) + ); + + // A block genuinely carrying 16 ticks passes. + let mut big_layout = Vec::new(); + for _ in 0..16 { + big_layout.push((25u64, 0usize)); + } + let (block, entries, _) = build_block(9, 5, [1u8; 32], &big_layout); + assert!(check_structure(&block, &entries, 4, Some(25)).is_empty()); + } + + #[test] + fn slot_zero_expects_one_slot_of_ticks() { + assert_eq!(expected_tick_count(0, 0, 64), 64); + assert_eq!(expected_tick_count(1, 0, 64), 64); + assert_eq!(expected_tick_count(5, 2, 64), 192); + } + + #[test] + fn entry_index_gap_short_circuits_dependent_checks() { + let (block, mut entries, _) = build_block(9, 8, [1u8; 32], &layout()); + entries.remove(2); + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::EntryIndexGap) + ); + // Tick-count and tiling checks are suppressed on unreliable indices. + assert!( + !findings + .iter() + .any(|finding| finding.code == FindingCode::TickCountMismatch) + ); + } + + #[test] + fn entry_count_mismatch_detected() { + let (mut block, entries, _) = build_block(9, 8, [1u8; 32], &layout()); + block.entry_count += 1; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::EntryCountMismatch) + ); + } + + #[test] + fn unknown_entry_count_skips_count_check() { + let (mut block, entries, _) = build_block(9, 8, [1u8; 32], &layout()); + block.entry_count = 0; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + !findings + .iter() + .any(|finding| finding.code == FindingCode::EntryCountMismatch) + ); + } + + #[test] + fn blockhash_mismatch_detected_without_hashing() { + let (mut block, entries, _) = build_block(9, 8, [1u8; 32], &layout()); + block.blockhash[0] ^= 0xff; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::BlockhashMismatch) + ); + } + + #[test] + fn trailing_transaction_entry_detected() { + let mut with_trailing_tx = layout(); + with_trailing_tx.push((7, 1)); + let (block, entries, _) = build_block(9, 8, [1u8; 32], &with_trailing_tx); + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::TrailingEntry) + ); + } + + #[test] + fn tick_hash_count_replica_detects_window_drift() { + let (block, mut entries, _) = build_block(9, 8, [1u8; 32], &layout()); + entries[1].num_hashes += 1; + let findings = check_structure(&block, &entries, 4, Some(25)); + let hits: Vec<_> = findings + .iter() + .filter(|finding| finding.code == FindingCode::TickHashCountMismatch) + .collect(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].entry_index, Some(2)); + } + + #[test] + fn zero_hash_tick_rejected() { + let (block, mut entries, _) = build_block(9, 8, [1u8; 32], &layout()); + entries[0].num_hashes = 0; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::TickHashCountMismatch) + ); + } + + #[test] + fn tick_hash_count_skipped_when_era_unknown_or_disabled() { + let (block, mut entries, _) = build_block(9, 8, [1u8; 32], &layout()); + entries[1].num_hashes += 1; + assert!( + !check_structure(&block, &entries, 4, None) + .iter() + .any(|finding| finding.code == FindingCode::TickHashCountMismatch) + ); + assert!( + !check_structure(&block, &entries, 4, Some(0)) + .iter() + .any(|finding| finding.code == FindingCode::TickHashCountMismatch) + ); + } + + #[test] + fn tx_tiling_gap_detected() { + let (block, mut entries, _) = build_block(9, 8, [1u8; 32], &layout()); + entries[4].starting_transaction_index += 1; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::TxIndexMismatch) + ); + } + + #[test] + fn tx_total_mismatch_detected() { + let (mut block, entries, _) = build_block(9, 8, [1u8; 32], &layout()); + block.executed_transaction_count += 1; + let findings = check_structure(&block, &entries, 4, Some(25)); + assert!( + findings + .iter() + .any(|finding| finding.code == FindingCode::TxIndexMismatch) + ); + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 85fe318..e334113 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -109,5 +109,35 @@ services: ports: - "${INGEST_METRICS_PORT:-9901}:9901" + superbank-verify: + profiles: + - verify + image: superbank:local + build: + context: . + dockerfile: Dockerfile + args: + SUPERBANK_RPC_FEATURES: ${SUPERBANK_RPC_FEATURES:-} + depends_on: + clickhouse-ddl: + condition: service_completed_successfully + entrypoint: + - /usr/local/bin/superbank-verify + # Default: structural sweep over everything present. Override with e.g. + # `docker compose run --rm superbank-verify --range 100:200 --mode full`. + command: + - --full + restart: "no" + environment: + RUST_LOG: ${RUST_LOG:-info} + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-default} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-superbank} + METRICS_HOST: 0.0.0.0 + METRICS_PORT: 9902 + ports: + - "${VERIFY_METRICS_PORT:-9902}:9902" + volumes: clickhouse-data: diff --git a/scripts/dev/run-verify-smoke.sh b/scripts/dev/run-verify-smoke.sh new file mode 100755 index 0000000..7e28ce3 --- /dev/null +++ b/scripts/dev/run-verify-smoke.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# +# Copyright 2025-2026 Triton One Limited. All rights reserved. +# +# End-to-end smoke test for superbank-verify: ingest a small range of real +# mainnet slots (blocks, transactions, entries) with the Jetstreamer smoke +# helper, then run full PoH verification over that range. + +set -euo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." + +if [[ $# -gt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + echo "usage: scripts/dev/run-verify-smoke.sh [epoch|start:end]" >&2 + exit 1 +fi + +range="${1:-358560000:358560099}" + +if [[ "${SKIP_INGEST:-}" != "1" ]]; then + scripts/dev/run-jetstreamer-entries-smoke.sh "${range}" +fi + +echo +echo "Running superbank-verify (mode=full) over ${range}" +CLICKHOUSE_URL="${CLICKHOUSE_URL:-http://localhost:8123}" \ +RUST_LOG="${RUST_LOG:-info}" \ +cargo run --release -p superbank-verify -- \ + --range "${range}" \ + --mode full \ + --allow-unverifiable diff --git a/superbank-verify.example.yaml b/superbank-verify.example.yaml new file mode 100644 index 0000000..c4fa337 --- /dev/null +++ b/superbank-verify.example.yaml @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# +# Example configuration for superbank-verify (Proof-of-History validator). +# Precedence: CLI flags > environment variables > this file > defaults. +# Pass with --config superbank-verify.yaml or SUPERBANK_VERIFY_CONFIG. + +# Range selection: exactly one of `range` / `full`. +# range: "250000000:250001000" # slots (a:b), epochs (a-b), or one epoch (a) +full: true + +# structural = invariants only (fast); full = recompute every SHA-256 hash. +mode: structural + +# Cluster shape (defaults are mainnet-beta). +slots-per-epoch: 432000 +epoch-warmup: true +ticks-per-slot: 64 +expected-genesis-hash: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d" +# hashes_per_tick eras ":,..." (0 disables the check for an +# era). Default: built-in mainnet history — only override for other clusters. +# hashes-per-tick-schedule: "0:12500" + +# External trust anchors, checked against recorded blockhashes. +# anchor: +# - "290000000:" + +# Throughput. +window-slots: 256 +fetch-ahead: 3 +verify-threads: 0 # 0 = all available cores + +# Resumability & reporting. +# checkpoint-file: /var/lib/superbank/verify.checkpoint.json +# resume: true +# report-file: /var/lib/superbank/verify-findings.jsonl +max-failures: 0 # abort after N failed slots (0 = unlimited) +allow-unverifiable: false + +# ClickHouse connection (same variables as the other superbank binaries). +clickhouse-url: "http://localhost:8123" +clickhouse-database: "default" +clickhouse-user: "default" +clickhouse-password: "" +blocks-table: "default.blocks_metadata" +entries-table: "default.entries" +transactions-table: "default.transactions" + +# Metrics / health. +metrics-host: "0.0.0.0" +metrics-port: 9902 +health-stale-secs: 300 +# metrics-cluster-label: "mainnet" + +progress-every-slots: 10000