diff --git a/.gitignore b/.gitignore index 360543b8..02378032 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,10 @@ .mcp.json # database -validator-data \ No newline at end of file +validator-data + +# coverage-replayer run data & artifacts (transferred out-of-band, never via git) +/data/ +*.profraw +*.pid +*.log diff --git a/AGENTS.md b/AGENTS.md index c29960f9..e889d11b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This file provides guidance to AI agents (e.g., Claude Code, Codex, Cursor, etc. ## Project Overview Stateless validator for MegaETH — validates blocks using SALT witness data without requiring full chain state. -The workspace contains two binaries: `stateless-validator` (chain-following validator) and `debug-trace-server` (RPC server for debug/trace methods). +The workspace contains three binaries: `stateless-validator` (chain-following validator), `debug-trace-server` (RPC server for debug/trace methods), and `coverage-replayer` (offline tool that derives the minimal mainnet block set maximizing mega-evm branch coverage). See `README.md` for detailed documentation and quickstart. ## Build & Development Commands @@ -34,14 +34,16 @@ The project uses nightly `2026-02-03` toolchain (edition 2024, rust-version 1.95 ## Workspace Structure -| Crate | Path | Purpose | -| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------ | -| `stateless-core` | `crates/stateless-core` | Storage traits, pipeline, EVM execution, SALT witness handling, chain spec, error types | -| `stateless-db` | `crates/stateless-db` | redb-backed persistence: table definitions, read/write helpers, `ContractCache` | -| `stateless-common` | `crates/stateless-common` | RPC client, metrics/logging utilities, witness size estimation | -| `stateless-test-utils` | `crates/stateless-test-utils` | Test fixtures (blocks, witnesses, contracts) and env-var lock for integration tests | -| `stateless-validator` | `bin/stateless-validator` | Main binary: chain sync, parallel validation workers (`app.rs` / `workers.rs` / `main.rs`) | -| `debug-trace-server` | `bin/debug-trace-server` | Standalone RPC server for debug/trace methods | +| Crate | Path | Purpose | +| ---------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `stateless-core` | `crates/stateless-core` | Storage traits, pipeline, EVM execution, SALT witness handling, chain spec, error types | +| `stateless-db` | `crates/stateless-db` | redb-backed persistence: table definitions, read/write helpers, `ContractCache` | +| `stateless-common` | `crates/stateless-common` | RPC client, metrics/logging utilities, witness size estimation | +| `stateless-test-utils` | `crates/stateless-test-utils` | Test fixtures (blocks, witnesses, contracts) and env-var lock for integration tests | +| `stateless-r2` | `crates/stateless-r2` | Shared R2 (S3) witness primitives: SigV4 signer, object-key layout, endpoint parsing, signed PUT; consumed by mega-reth's uploaders (write) and the validator's R2 witness source (read) | +| `stateless-validator` | `bin/stateless-validator` | Main binary: chain sync, parallel validation workers (`app.rs` / `workers.rs` / `main.rs`) | +| `debug-trace-server` | `bin/debug-trace-server` | Standalone RPC server for debug/trace methods | +| `coverage-replayer` | `bin/coverage-replayer` | Offline coverage tool: replays blocks under LLVM branch instrumentation (`backfill`), dedups per-block coverage bitmaps into patterns, and computes the minimal covering block set (`set-cover` / `report` / `inspect` / `merge`). Requires the instrumented `[profile.coverage]` build | Additional directories: `test_data/` (integration test fixtures including genesis config), `audits/` (security audit reports). @@ -109,26 +111,26 @@ The server includes an HTTP response cache (`quick_cache`) for pre-serialized JS ### Key Source Files -| File | Purpose | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `crates/stateless-core/src/pipeline/{mod,config,traits,fetcher,divergence,advancer,worker}.rs` | Generic three-stage pipeline split by responsibility | -| `crates/stateless-core/src/executor.rs` | Block validation and EVM replay (generic over the `BlockInput` projection) | -| `crates/stateless-core/src/evm_database.rs` | WitnessDatabase implementing `revm::DatabaseRef` | -| `crates/stateless-core/src/db.rs` | Shared storage traits (`ContractStore`, `ChainStore`) + `StoreError` / `StoreResult` | -| `crates/stateless-core/src/withdrawals.rs` | Withdrawal validation and MPT witness handling | -| `crates/stateless-db/src/{lib,tables,helpers,serialize,cache}.rs` | Shared redb tables, helpers, serialization, and `ContractCache` | -| `crates/stateless-common/src/rpc_client.rs` | RPC client for blocks, witnesses, and bytecode | -| `crates/stateless-common/src/metrics.rs` | RpcMethod, RpcMetrics, RpcClientConfig | -| `bin/stateless-validator/src/{main,app,workers,chain_sync,validator_db,metrics}.rs` | Thin entry, CLI/startup wiring, pipeline+reporter, fetcher/processor, DB | -| `bin/debug-trace-server/src/chain_sync.rs` | TraceFetcher, TraceProcessor, TraceHooks | -| `bin/debug-trace-server/src/rpc_service.rs` | RPC method definitions and handlers | -| `bin/debug-trace-server/src/data_provider.rs` | Block data fetching with single-flight coalescing | -| `bin/debug-trace-server/src/server_db.rs` | Defines + implements the bin-local `BlockStore` trait (backed by `stateless-db`) | +| File | Purpose | +| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `crates/stateless-core/src/pipeline/{mod,config,traits,fetcher,divergence,advancer,worker}.rs` | Generic three-stage pipeline split by responsibility | +| `crates/stateless-core/src/executor.rs` | Block validation and EVM replay (generic over the `BlockInput` projection) | +| `crates/stateless-core/src/evm_database.rs` | WitnessDatabase implementing `revm::DatabaseRef` | +| `crates/stateless-core/src/db.rs` | Shared storage traits (`ContractStore`, `ChainStore`) + `StoreError` / `StoreResult` | +| `crates/stateless-core/src/withdrawals.rs` | Withdrawal validation and MPT witness handling | +| `crates/stateless-db/src/{lib,tables,helpers,serialize,cache}.rs` | Shared redb tables, helpers, serialization, and `ContractCache` | +| `crates/stateless-common/src/rpc_client.rs` | RPC client for blocks, witnesses, and bytecode | +| `crates/stateless-common/src/metrics.rs` | RpcMethod, RpcMetrics, RpcClientConfig | +| `bin/stateless-validator/src/{main,app,workers,chain_sync,validator_db,metrics}.rs` | Thin entry, CLI/startup wiring, pipeline+reporter, fetcher/processor, DB | +| `bin/debug-trace-server/src/chain_sync.rs` | TraceFetcher, TraceProcessor, TraceHooks | +| `bin/debug-trace-server/src/rpc_service.rs` | RPC method definitions and handlers | +| `bin/debug-trace-server/src/data_provider.rs` | Block data fetching with single-flight coalescing | +| `bin/debug-trace-server/src/server_db.rs` | Defines + implements the bin-local `BlockStore` trait (backed by `stateless-db`) | ## Test Organization Unit tests are embedded in source files alongside the code they test. -Integration tests live in `bin/debug-trace-server/tests/` (6 modules: cache_metrics, block_tag, consistency, performance, timing_header, prune) and in `bin/stateless-validator/tests/integration.rs` (CLI parsing, mock-RPC pipeline, mainnet single-block validation). +Integration tests live in `bin/debug-trace-server/tests/` (5 modules: cache_metrics, block_tag, consistency, performance, timing_header), in `bin/stateless-validator/tests/integration.rs` (CLI parsing, mock-RPC pipeline, mainnet single-block validation), and in `bin/coverage-replayer/tests/replay_fixtures.rs` (worker replay glue over the `test_data/mainnet` fixtures). Test data (block JSON files, contract bytecode, witness data) is stored in `test_data/`. ## Version Control diff --git a/Cargo.lock b/Cargo.lock index b4b894b0..52bd2c14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -376,7 +376,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "thiserror 2.0.17", @@ -420,7 +420,7 @@ dependencies = [ "alloy-transport-http", "futures", "pin-project", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "tokio", @@ -620,7 +620,7 @@ checksum = "90aa6825760905898c106aba9c804b131816a15041523e80b6d4fe7af6380ada" dependencies = [ "alloy-json-rpc", "alloy-transport", - "reqwest 0.12.24", + "reqwest", "serde_json", "tower", "tracing", @@ -1731,6 +1731,37 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "coverage-replayer" +version = "2.0.14" +dependencies = [ + "alloy-genesis", + "alloy-primitives", + "alloy-rpc-types-eth", + "bincode 2.0.1", + "chrono", + "clap", + "eyre", + "kanal", + "num_cpus", + "op-alloy-rpc-types", + "redb", + "reqwest", + "revm", + "rustc-hash", + "serde", + "serde_json", + "stateless-common", + "stateless-core", + "stateless-r2", + "stateless-test-utils", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber 0.3.23", + "zstd", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1916,10 +1947,9 @@ dependencies = [ "quick_cache", "rayon", "redb", - "reqwest 0.13.2", + "reqwest", "revm", "revm-inspectors", - "salt", "serde", "serde_json", "stateless-common", @@ -4459,6 +4489,7 @@ dependencies = [ "async-compression", "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -4491,39 +4522,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "reth-chainspec" version = "1.6.0" @@ -5701,7 +5699,7 @@ dependencies = [ "kanal", "op-alloy-network", "op-alloy-rpc-types", - "reqwest 0.12.24", + "reqwest", "revm", "rolling-file", "salt", @@ -5776,6 +5774,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "stateless-r2" +version = "2.0.14" +dependencies = [ + "bytes", + "chrono", + "hex", + "hmac", + "percent-encoding", + "reqwest", + "sha2 0.10.9", +] + [[package]] name = "stateless-test-utils" version = "2.0.14" @@ -5801,6 +5812,8 @@ dependencies = [ "alloy-genesis", "alloy-primitives", "alloy-rpc-types-eth", + "bytes", + "chrono", "clap", "eyre", "jsonrpsee", @@ -5809,12 +5822,14 @@ dependencies = [ "metrics-exporter-prometheus", "op-alloy-rpc-types", "redb", + "reqwest", "revm", "salt", "serde_json", "stateless-common", "stateless-core", "stateless-db", + "stateless-r2", "stateless-test-utils", "tempfile", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index cebc1515..1626b27d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,12 @@ [workspace] members = [ + "bin/coverage-replayer", "bin/debug-trace-server", "bin/stateless-validator", "crates/stateless-common", "crates/stateless-core", "crates/stateless-db", + "crates/stateless-r2", "crates/stateless-test-utils", ] resolver = "2" @@ -62,6 +64,7 @@ revm-inspectors = { version = "0.27.3", features = ["std", "js-tracer"], default base64 = { version = "0.22", default-features = false } bincode = { version = "2.0", features = ["serde", "alloc"], default-features = false } bytes = "1.11" +chrono = { version = "0.4", default-features = false } clap = { version = "4.6", features = ["derive", "env", "std"], default-features = false } dashmap = { version = "6.1", default-features = false } dotenvy = "0.15" @@ -70,6 +73,8 @@ eyre = { version = "0.6", features = ["auto-install"], default-features = false fastrand = { version = "2.4", default-features = false } futures = { version = "0.3", default-features = false } hashbrown = { version = "0.16", default-features = false } +hex = { version = "0.4", default-features = false } +hmac = { version = "0.12", default-features = false } http = "1.4" http-body = "1.0" http-body-util = "0.1" @@ -82,15 +87,17 @@ metrics = "0.24" metrics-derive = "0.1" metrics-exporter-prometheus = { version = "0.18", features = ["http-listener"], default-features = false } num_cpus = "1.17" +percent-encoding = { version = "2.3", default-features = false } pin-project-lite = "0.2" quick_cache = { version = "0.6", default-features = false } rayon = "1.11" redb = "4.0" -reqwest = { version = "0.13", features = ["json", "blocking"], default-features = false } +reqwest = { version = "0.12", default-features = false } rolling-file = "0.2" rustc-hash = { version = "2.1", default-features = false } serde = { version = "1.0", default-features = false, features = ["alloc", "derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +sha2 = { version = "0.10", default-features = false } tempfile = { version = "3.27", default-features = false } thiserror = { version = "2.0", default-features = false } tokio = { version = "1.51", features = ["rt-multi-thread", "signal"], default-features = false } @@ -113,3 +120,18 @@ opt-level = 3 debug-assertions = true incremental = true debug = true + +# Instrumented builds for coverage-replayer: coverage only cares about "was it +# executed", so trade peak runtime speed for much faster builds (no LTO, many +# codegen units). Do NOT add `-C link-dead-code` (it monomorphizes dead generic +# code and fails const-eval asserts in revm). Pass an explicit --target so +# RUSTFLAGS skips host artifacts (otherwise instrumented proc-macros make every +# rustc invocation drop default_*.profraw files into the cwd). Build with: +# RUSTFLAGS="-C instrument-coverage -Z coverage-options=branch" \ +# cargo build --profile coverage -p coverage-replayer --features coverage \ +# --target "$(rustc -vV | sed -n 's/host: //p')" +[profile.coverage] +inherits = "release" +opt-level = 2 +lto = "off" +codegen-units = 16 diff --git a/README.md b/README.md index 88d5b2d9..f1bd5949 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,18 @@ The stateless approach eliminates the need for validators to run on high-end har ## Project Structure -The workspace contains two binaries and four library crates: - -| Crate | Path | Purpose | -| ---------------------- | ----------------------------- | ----------------------------------------------------------------------------------- | -| `stateless-core` | `crates/stateless-core` | Core validation logic, abstract storage traits, generic pipeline, EVM execution | -| `stateless-db` | `crates/stateless-db` | redb-backed persistence: table definitions, read/write helpers, bounded `ContractCache` | -| `stateless-common` | `crates/stateless-common` | Shared utilities: RPC client, logging, metrics | -| `stateless-test-utils` | `crates/stateless-test-utils` | Test fixtures (blocks, witnesses, contracts) and env-var lock for integration tests | -| `stateless-validator` | `bin/stateless-validator` | Main binary: chain sync, parallel validation workers | -| `debug-trace-server` | `bin/debug-trace-server` | Standalone RPC server for debug/trace methods | +The workspace contains three binaries and five library crates: + +| Crate | Path | Purpose | +| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `stateless-core` | `crates/stateless-core` | Core validation logic, abstract storage traits, generic pipeline, EVM execution | +| `stateless-db` | `crates/stateless-db` | redb-backed persistence: table definitions, read/write helpers, bounded `ContractCache` | +| `stateless-common` | `crates/stateless-common` | Shared utilities: RPC client, logging, metrics | +| `stateless-test-utils` | `crates/stateless-test-utils` | Test fixtures (blocks, witnesses, contracts) and env-var lock for integration tests | +| `stateless-r2` | `crates/stateless-r2` | Shared R2 (S3) witness primitives: SigV4 signer, object-key layout, endpoint parsing, signed PUT; consumed by mega-reth's witness uploaders (write) and this repo's validator (read) | +| `stateless-validator` | `bin/stateless-validator` | Main binary: chain sync, parallel validation workers | +| `debug-trace-server` | `bin/debug-trace-server` | Standalone RPC server for debug/trace methods | +| `coverage-replayer` | `bin/coverage-replayer` | Offline coverage tool: replays mainnet blocks under LLVM branch instrumentation and derives the minimal block set maximizing mega-evm coverage | Additional directories: `test_data/` (integration test fixtures including genesis config), `audits/` (security audit reports). @@ -67,10 +69,14 @@ cargo run --release --bin stateless-validator -- \ - `--witness-endpoint`: MegaETH JSON-RPC API endpoint URL(s) to retrieve witness data. Multiple endpoints can be provided via repeated flags or as a comma-separated list (tried in order on failure). The env var `STATELESS_VALIDATOR_WITNESS_ENDPOINT` accepts the same comma-separated form (e.g. `http://a:8545,http://b:8545`). + Required with `--witness-source rpc` (the default); ignored with `--witness-source r2`. **Optional Arguments:** - `--genesis-file`: Path to genesis JSON file containing hardfork activation configuration (required on first run, stored in database for subsequent runs) - `--start-block`: Trusted block hash to initialize validation from (required for first-time setup) +- `--end-block`: Inclusive end block; validate up to this height, then stop cleanly (useful to slice a fixed range across multiple servers) +- `--witness-source`: Where to fetch witnesses from: `rpc` (default) or `r2` (straight from the R2 bucket over the S3 API) +- `--r2-endpoint`, `--r2-bucket`, `--r2-access-key-id`, `--r2-secret-access-key`: R2 connection settings, all required with `--witness-source r2` (prefer the env var for the secret) - `--report-validation-endpoint`: RPC endpoint URL for reporting validated blocks via `mega_setValidatedBlocks` (disabled if not provided) - `--metrics-enabled`: Enable Prometheus metrics endpoint (disabled by default) - `--metrics-port`: Port for Prometheus metrics HTTP endpoint (default: 9090) @@ -102,6 +108,9 @@ Each command-line flag has an equivalent environment variable: - `STATELESS_VALIDATOR_WITNESS_ENDPOINT` → `--witness-endpoint` - `STATELESS_VALIDATOR_GENESIS_FILE` → `--genesis-file` - `STATELESS_VALIDATOR_START_BLOCK` → `--start-block` +- `STATELESS_VALIDATOR_END_BLOCK` → `--end-block` +- `STATELESS_VALIDATOR_WITNESS_SOURCE` → `--witness-source` +- `STATELESS_VALIDATOR_R2_ENDPOINT` / `_R2_BUCKET` / `_R2_ACCESS_KEY_ID` / `_R2_SECRET_ACCESS_KEY` → `--r2-*` - `STATELESS_VALIDATOR_REPORT_VALIDATION_ENDPOINT` → `--report-validation-endpoint` - `STATELESS_VALIDATOR_METRICS_ENABLED` → `--metrics-enabled` (set to `true` to enable) - `STATELESS_VALIDATOR_METRICS_PORT` → `--metrics-port` @@ -197,23 +206,23 @@ The pipeline is configured via `PipelineConfig` and customized through trait imp ### Key Source Files -| File | Purpose | -| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `crates/stateless-core/src/pipeline/{mod,config,traits,fetcher,divergence,advancer,worker}.rs` | Generic three-stage pipeline split by responsibility | -| `crates/stateless-core/src/executor.rs` | Block validation and EVM replay | -| `crates/stateless-core/src/db.rs` | Shared storage traits: `ChainStore`, `ContractStore`, `StoreError` (scenario stores live in their binaries) | -| `crates/stateless-core/src/evm_database.rs` | `WitnessDatabase` implementing `revm::DatabaseRef` | -| `crates/stateless-core/src/withdrawals.rs` | Withdrawal validation and MPT witness handling | -| `crates/stateless-db/src/{lib,tables,helpers,serialize,cache}.rs` | Shared redb tables, helpers, serialization, and bounded `ContractCache` | -| `crates/stateless-common/src/rpc_client.rs` | `RpcClient`: multi-endpoint HTTP client for blocks, witnesses, and bytecode | -| `crates/stateless-common/src/metrics.rs` | `RpcMethod`, `RpcMetrics`, `RpcClientConfig` | -| `crates/stateless-common/src/witness_size.rs` | `WitnessSizeBreakdown` + `estimate_witness_size` for RPC and trace-server metrics | -| `crates/stateless-test-utils/src/fixtures.rs` | `TestFixtures` loader (blocks, SALT/MPT witnesses, contracts, genesis) | -| `bin/stateless-validator/src/{main,app,workers,chain_sync,validator_db,metrics}.rs` | Thin entry, CLI/startup wiring, pipeline+reporter, fetcher/processor, DB | -| `bin/debug-trace-server/src/chain_sync.rs` | `TraceFetcher`, `TraceProcessor`, `TraceHooks` | -| `bin/debug-trace-server/src/rpc_service.rs` | RPC method definitions and handlers | -| `bin/debug-trace-server/src/data_provider.rs` | Block data fetching with single-flight coalescing | -| `bin/debug-trace-server/src/server_db.rs` | Defines + implements the bin-local `BlockStore` trait, backed by `stateless-db` | +| File | Purpose | +| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `crates/stateless-core/src/pipeline/{mod,config,traits,fetcher,divergence,advancer,worker}.rs` | Generic three-stage pipeline split by responsibility | +| `crates/stateless-core/src/executor.rs` | Block validation and EVM replay | +| `crates/stateless-core/src/db.rs` | Shared storage traits: `ChainStore`, `ContractStore`, `StoreError` (scenario stores live in their binaries) | +| `crates/stateless-core/src/evm_database.rs` | `WitnessDatabase` implementing `revm::DatabaseRef` | +| `crates/stateless-core/src/withdrawals.rs` | Withdrawal validation and MPT witness handling | +| `crates/stateless-db/src/{lib,tables,helpers,serialize,cache}.rs` | Shared redb tables, helpers, serialization, and bounded `ContractCache` | +| `crates/stateless-common/src/rpc_client.rs` | `RpcClient`: multi-endpoint HTTP client for blocks, witnesses, and bytecode | +| `crates/stateless-common/src/metrics.rs` | `RpcMethod`, `RpcMetrics`, `RpcClientConfig` | +| `crates/stateless-common/src/witness_size.rs` | `WitnessSizeBreakdown` + `estimate_witness_size` for RPC and trace-server metrics | +| `crates/stateless-test-utils/src/fixtures.rs` | `TestFixtures` loader (blocks, SALT/MPT witnesses, contracts, genesis) | +| `bin/stateless-validator/src/{main,app,workers,chain_sync,validator_db,metrics}.rs` | Thin entry, CLI/startup wiring, pipeline+reporter, fetcher/processor, DB | +| `bin/debug-trace-server/src/chain_sync.rs` | `TraceFetcher`, `TraceProcessor`, `TraceHooks` | +| `bin/debug-trace-server/src/rpc_service.rs` | RPC method definitions and handlers | +| `bin/debug-trace-server/src/data_provider.rs` | Block data fetching with single-flight coalescing | +| `bin/debug-trace-server/src/server_db.rs` | Defines + implements the bin-local `BlockStore` trait, backed by `stateless-db` | ### Database diff --git a/bin/coverage-replayer/Cargo.toml b/bin/coverage-replayer/Cargo.toml new file mode 100644 index 00000000..23942a9a --- /dev/null +++ b/bin/coverage-replayer/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "coverage-replayer" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true + +[dependencies] +# alloy +alloy-genesis.workspace = true +alloy-primitives.workspace = true +alloy-rpc-types-eth.workspace = true + +# op +op-alloy-rpc-types.workspace = true + +# revm +revm.workspace = true + +# stateless +stateless-common = { path = "../../crates/stateless-common" } +stateless-core = { path = "../../crates/stateless-core" } +stateless-r2 = { path = "../../crates/stateless-r2" } + +# misc +bincode.workspace = true +chrono = { workspace = true, features = ["clock"] } +clap.workspace = true +eyre.workspace = true +kanal.workspace = true +num_cpus.workspace = true +redb.workspace = true +reqwest = { workspace = true, features = ["rustls-tls"] } +rustc-hash.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "macros", "process", "sync", "time"] } +tracing.workspace = true +tracing-subscriber.workspace = true +zstd.workspace = true + +[dev-dependencies] +stateless-test-utils = { path = "../../crates/stateless-test-utils" } +tempfile.workspace = true + +[features] +# Enables the FFI calls into the LLVM profiler runtime. Only meaningful when the +# binary is built with RUSTFLAGS="-C instrument-coverage ..." (which links the +# runtime); regular builds keep this off so the symbols are never referenced. +coverage = [] diff --git a/bin/coverage-replayer/build.rs b/bin/coverage-replayer/build.rs new file mode 100644 index 00000000..438755aa --- /dev/null +++ b/bin/coverage-replayer/build.rs @@ -0,0 +1,71 @@ +//! Captures a fingerprint of the coverage-relevant build at compile time. +//! +//! The coverage namespace (counter ids) is determined by the instrumented +//! mega-evm build, NOT by this binary's orchestration code. Basing the store's +//! `binary_id` on this fingerprint (rather than a whole-exe hash) means editing +//! the dispatcher / adding subcommands does not invalidate an existing store — +//! only a real mega-evm or toolchain/target change does. + +use std::process::Command; + +fn main() { + // mega-evm's locked git revision from the workspace lockfile. This is the + // namespace anchor, so a missing rev is a hard build error rather than a + // silent fallback: overriding mega-evm to a path dependency would + // otherwise collapse genuinely different builds into one store namespace. + let mega_evm = std::fs::read_to_string("../../Cargo.lock") + .ok() + .and_then(|lock| mega_evm_rev(&lock)) + .expect( + "coverage-replayer build: no git revision for `mega-evm` in Cargo.lock. \ + The store namespace (binary_id) is anchored on that rev; if you are \ + deliberately overriding mega-evm with a path dependency, extend build.rs \ + to fingerprint the override instead of building with a broken namespace.", + ); + + // `rustc -vV` includes `release:`, `host:`, and `LLVM version:` lines — + // the plain `--version` string carries none of those, and both the host + // triple and the LLVM version can shift counter ids. Fingerprint all + // three lines. + let rustc_vv = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into())) + .arg("-vV") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .expect("coverage-replayer build: `rustc -vV` failed"); + let toolchain: String = rustc_vv + .lines() + .filter(|l| { + l.starts_with("release:") || l.starts_with("host:") || l.starts_with("LLVM version:") + }) + .collect::>() + .join(";"); + assert!( + toolchain.contains("host:") && toolchain.contains("release:"), + "coverage-replayer build: unexpected `rustc -vV` output: {rustc_vv:?}" + ); + + println!("cargo:rustc-env=COVERAGE_MEGA_EVM_REV={mega_evm}"); + println!("cargo:rustc-env=COVERAGE_RUSTC_VERSION={toolchain}"); + // Re-run if the lockfile changes (mega-evm bump). + println!("cargo:rerun-if-changed=../../Cargo.lock"); +} + +/// Extracts the full git revision of the `mega-evm` package from Cargo.lock. +/// The source line looks like: +/// `source = "git+https://github.com/megaeth-labs/mega-evm.git?tag=vX#"`. +fn mega_evm_rev(lock: &str) -> Option { + let mut in_mega = false; + for line in lock.lines() { + let line = line.trim(); + if line == "name = \"mega-evm\"" { + in_mega = true; + } else if in_mega && line.starts_with("source = ") && line.contains("mega-evm.git") { + return line.rsplit('#').next().map(|s| s.trim_end_matches('"').to_string()); + } else if line.starts_with("[[package]]") { + in_mega = false; + } + } + None +} diff --git a/bin/coverage-replayer/src/backfill.rs b/bin/coverage-replayer/src/backfill.rs new file mode 100644 index 00000000..bc4a5f13 --- /dev/null +++ b/bin/coverage-replayer/src/backfill.rs @@ -0,0 +1,1271 @@ +//! Backfill driver: fetch a block range → spool → resident worker pool → +//! judge (pattern dedup, promotion, persistence). +//! +//! Data flow (all stages run concurrently, no barriers): +//! +//! ```text +//! fetch tasks (F) ──spool file──▶ dispatch queue ──▶ worker managers (N, one child each) +//! │ WorkerResponse +//! ▼ +//! judge (single consumer, owns redb) +//! ``` + +use std::{ + collections::HashMap, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy_primitives::B256; +use alloy_rpc_types_eth::BlockId; +use clap::Args; +use eyre::{Context, Result, ensure}; +use stateless_common::RpcClient; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt}, + process::Child, + task::JoinSet, +}; +use tracing::{info, warn}; + +use crate::{ + bitset::BitSet, + llvm, + proto::{WorkerRequest, WorkerResponse}, + spool::{DataDir, SpoolEntry, write_atomic}, + store::{ + BlockRecord, BlockStatus, CounterInfo, PatternRecord, Store, current_binary_id, + elapsed_stats, resolve_pattern_slot, + }, +}; + +/// Witness source selector (mirrors the validator's `--witness-source`). +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WitnessSource { + /// `mega_getBlockWitness` RPC. + #[default] + Rpc, + /// Straight from the R2 bucket over the S3 API. Requires the `--r2-*` flags. + R2, +} + +#[derive(Args, Debug, Clone)] +pub struct BackfillArgs { + /// First block of the range (inclusive). + #[clap(long)] + pub from: u64, + /// Last block of the range (inclusive). + #[clap(long)] + pub to: u64, + /// Data RPC endpoint(s) (blocks, bytecode). + #[clap( + long = "rpc-endpoint", + env = "COVERAGE_REPLAYER_RPC_ENDPOINT", + value_delimiter = ',', + required = true + )] + pub rpc_endpoints: Vec, + /// Witness RPC endpoint(s) (`mega_getBlockWitness`). Required with + /// `--witness-source rpc` (the default); ignored with `r2`. + #[clap( + long = "witness-endpoint", + env = "COVERAGE_REPLAYER_WITNESS_ENDPOINT", + value_delimiter = ',' + )] + pub witness_endpoints: Vec, + /// Where to source witnesses from: `rpc` (default) or `r2` (straight from + /// the R2 bucket over the S3 API; requires the `--r2-*` flags). + #[clap(long, env = "COVERAGE_REPLAYER_WITNESS_SOURCE", value_enum, default_value_t = WitnessSource::Rpc)] + pub witness_source: WitnessSource, + /// R2 S3 endpoint origin, e.g. `https://.r2.cloudflarestorage.com` + /// (no bucket path). Required when `--witness-source r2`. + #[clap(long, env = "COVERAGE_REPLAYER_R2_ENDPOINT")] + pub r2_endpoint: Option, + /// R2 bucket holding the witnesses (e.g. `witness-mainnet`). Required when + /// `--witness-source r2`. + #[clap(long, env = "COVERAGE_REPLAYER_R2_BUCKET")] + pub r2_bucket: Option, + /// R2 access key id (Object Read). Required when `--witness-source r2`. + #[clap(long, env = "COVERAGE_REPLAYER_R2_ACCESS_KEY_ID")] + pub r2_access_key_id: Option, + /// R2 secret access key. Required when `--witness-source r2`. Prefer the + /// env var over the flag. Redacted in `Debug` output. + #[clap(long, env = "COVERAGE_REPLAYER_R2_SECRET_ACCESS_KEY")] + pub r2_secret_access_key: Option, + /// Genesis JSON path (e.g. test_data/mainnet/genesis.json). + #[clap(long, env = "COVERAGE_REPLAYER_GENESIS_FILE")] + pub genesis_file: String, + /// Root directory for spool/codes/archive/store. + #[clap(long, env = "COVERAGE_REPLAYER_DATA_DIR")] + pub data_dir: PathBuf, + /// Number of resident worker subprocesses (default: cores - 2). + #[clap(long, env = "COVERAGE_REPLAYER_WORKERS")] + pub workers: Option, + /// Concurrent block fetches. + #[clap(long, default_value_t = 8)] + pub fetch_concurrency: usize, + /// Substring filter on PGO symbol names (coverage universe scope). + #[clap(long, env = "COVERAGE_REPLAYER_SYMBOL_FILTER", default_value = "mega_evm")] + pub symbol_filter: String, + /// Explicit llvm-profdata path (default: auto-detect via rustc sysroot). + #[clap(long)] + pub llvm_profdata: Option, + /// Interval (seconds) for the "block still executing" progress warning. + /// Blocks are NEVER timed out or skipped — a stuck block stays visibly + /// stuck in the log until it completes. + #[clap(long, default_value_t = 600)] + pub slow_block_warn_secs: u64, +} + +pub async fn run(args: BackfillArgs) -> Result<()> { + ensure!(args.from <= args.to, "--from must be <= --to"); + ensure!( + crate::profile_rt::is_instrumented_build(), + "backfill requires the instrumented build (see [profile.coverage] in Cargo.toml)" + ); + + let dirs = Arc::new(DataDir::new(&args.data_dir)); + dirs.ensure_layout()?; + let binary_id = current_binary_id(); + info!(binary_id, "opening store"); + let store = Store::open(&dirs.store_path(), &binary_id, Some(&args.symbol_filter))?; + // Writers killed mid-write_atomic leave uniquely-named *.tmp files that + // would otherwise accumulate forever across crashes. Sweep only AFTER + // Store::open: its exclusive redb lock guarantees no other backfill is + // live on this data-dir (a doomed double-start must be refused before it + // can delete a live writer's tmp files); the age threshold protects + // non-locking processes like a concurrent `report`. + let swept: usize = [dirs.spool(), dirs.codes(), dirs.tmp(), dirs.archive_profiles()] + .iter() + .map(|d| crate::spool::sweep_stale_tmp(d, Duration::from_secs(3600))) + .sum(); + if swept > 0 { + info!(swept, "removed stale tmp files from a previous crash"); + } + let snapshot = store.load_for_range(args.from..=args.to)?; + let llvm_profdata = llvm::find_tool("llvm-profdata", args.llvm_profdata.as_deref())?; + info!(llvm_profdata = %llvm_profdata.display(), "llvm tools resolved"); + + // R2 witness source: witnesses come from the bucket, so the RPC witness + // endpoints are unused — feed the data endpoints in as placeholders (the + // RpcClient requires a non-empty list). + let r2 = match args.witness_source { + WitnessSource::Rpc => { + ensure!( + !args.witness_endpoints.is_empty(), + "--witness-endpoint is required with --witness-source rpc" + ); + None + } + WitnessSource::R2 => { + // The value itself is Debug-redacted, but a CLI-passed secret is + // still visible in the process list for the whole (multi-week) + // run. Detect "flag, not env" and nudge loudly. + if args.r2_secret_access_key.is_some() && + std::env::var("COVERAGE_REPLAYER_R2_SECRET_ACCESS_KEY").is_err() + { + warn!( + "--r2-secret-access-key was passed on the command line — it is visible in \ + `ps` for the lifetime of the process; prefer the \ + COVERAGE_REPLAYER_R2_SECRET_ACCESS_KEY env var" + ); + } + let require = |v: Option, flag: &str| { + v.filter(|s| !s.is_empty()).ok_or_else(|| { + eyre::eyre!("{flag} is required (and non-empty) with --witness-source r2") + }) + }; + let client = crate::r2::R2LightClient::new( + &require(args.r2_endpoint.clone(), "--r2-endpoint")?, + require(args.r2_bucket.clone(), "--r2-bucket")?, + require(args.r2_access_key_id.clone(), "--r2-access-key-id")?, + require( + args.r2_secret_access_key.as_ref().map(|s| s.as_ref().to_string()), + "--r2-secret-access-key", + )?, + Duration::from_secs(60), + )?; + info!("witness source: R2 (light decode)"); + Some(Arc::new(client)) + } + }; + + let data_apis: Vec = args.rpc_endpoints.clone(); + let witness_apis: Vec = + if r2.is_some() { data_apis.clone() } else { args.witness_endpoints.clone() }; + let client = Arc::new(RpcClient::new( + &data_apis.iter().map(String::as_str).collect::>(), + &witness_apis.iter().map(String::as_str).collect::>(), + )?); + + let latest = client.get_latest_block_number().await; + ensure!( + args.to <= latest, + "--to {} is beyond the chain tip {latest}; refusing to wait on unfetchable blocks", + args.to + ); + + // Work list: skip only blocks that previously replayed CLEANLY. Error / + // Divergent records are retried — no block is ever permanently excluded. + let todo: Vec = (args.from..=args.to) + .filter(|n| !matches!(snapshot.blocks.get(n), Some(r) if r.status == BlockStatus::Ok)) + .collect(); + let retrying = todo.iter().filter(|n| snapshot.blocks.contains_key(n)).count(); + let total = todo.len() as u64; + info!( + range = %format!("{}..={}", args.from, args.to), + todo = total, + skipped = (args.to - args.from + 1) - total, + retrying_quarantined = retrying, + "backfill starting" + ); + if todo.is_empty() { + info!("nothing to do"); + return Ok(()); + } + + let workers = args.workers.unwrap_or_else(|| num_cpus::get().saturating_sub(2).max(1)); + ensure!(workers >= 1, "--workers must be at least 1"); + let (dispatch_tx, dispatch_rx) = kanal::bounded_async::(workers * 2); + let (judged_tx, mut judged_rx) = tokio::sync::mpsc::channel::(workers * 2); + + // ---- worker managers ---- + let mut manager_set = JoinSet::new(); + for id in 0..workers { + let rx = dispatch_rx.clone(); + let tx = judged_tx.clone(); + let dirs = dirs.clone(); + let args = args.clone(); + let llvm_profdata = llvm_profdata.clone(); + manager_set.spawn(async move { + worker_manager(id, rx, tx, dirs, args, llvm_profdata).await; + }); + } + drop(dispatch_rx); + drop(judged_tx); + + // ---- fetch stage ---- + let fetcher = { + let dirs = dirs.clone(); + let client = client.clone(); + let r2 = r2.clone(); + let dispatch_tx = dispatch_tx.clone(); + let fetch_concurrency = args.fetch_concurrency.max(1); + tokio::spawn(async move { + let mut inflight: JoinSet = JoinSet::new(); + for n in todo { + while inflight.len() >= fetch_concurrency { + if let Some(done) = inflight.join_next().await { + forward_fetched(done, &dispatch_tx).await; + } + } + let dirs = dirs.clone(); + let client = client.clone(); + let r2 = r2.clone(); + // Retry until success — a block is never skipped. Transient + // RPC/IO failures resolve on retry; a persistent failure loops + // visibly in the log until the operator intervenes. + inflight.spawn(async move { + let mut attempt = 0u64; + let mut block_cache = None; + loop { + match fetch_block(&client, r2.as_deref(), &dirs, n, &mut block_cache).await + { + Ok(()) => break n, + Err(e) => { + attempt += 1; + warn!( + block = n, + attempt, + error = %format!("{e:#}"), + "fetch failed; retrying in 5s (blocks are never skipped)" + ); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + }); + } + while let Some(done) = inflight.join_next().await { + forward_fetched(done, &dispatch_tx).await; + } + }) + }; + drop(dispatch_tx); + + // ---- judge (this task) ---- + let mut judge = JudgeState::new(snapshot, &store, dirs.clone(), total, llvm_profdata.clone()); + while let Some(outcome) = judged_rx.recv().await { + judge.ingest(outcome)?; + } + + fetcher.await.ok(); + while manager_set.join_next().await.is_some() {} + judge.final_summary(); + Ok(()) +} + +async fn forward_fetched( + done: std::result::Result, + dispatch_tx: &kanal::AsyncSender, +) { + match done { + Ok(n) => { + // Queue closed (all managers dead) is fatal-ish; just log. + if dispatch_tx.send(n).await.is_err() { + warn!(block = n, "dispatch queue closed, dropping fetched block"); + } + } + // A panic in a fetch task is a code bug; the block stays absent from + // the store, so a re-run picks it up. Loud, not silent. + Err(e) => tracing::error!(error = %e, "fetch task panicked — block will need a re-run"), + } +} + +/// Fetches one block + witness, resolves missing bytecodes, writes the spool +/// entry. Skips work that already exists on disk (crash resume). +/// +/// `block_cache` holds the fetched block across the caller's retry rounds so +/// a witness-side failure (e.g. R2 404 looping under retry-forever) does not +/// re-download the full block every 5 seconds. +async fn fetch_block( + client: &RpcClient, + r2: Option<&crate::r2::R2LightClient>, + dirs: &DataDir, + n: u64, + block_cache: &mut Option>, +) -> Result<()> { + let spool_path = dirs.spool_entry(n); + if spool_path.exists() { + // Trust nothing left on disk: a spool the worker cannot use (crash + // artifact, a SpoolEntry layout change between binary versions, a + // corrupt inner block_json, or a wrong-numbered block) would + // otherwise poison the worker on EVERY restart — the judge + // fail-stops on it, the re-run skips the fetch because the file + // exists, and the block can never complete. Validate exactly what + // the worker will check and refetch on failure so every block + // eventually executes. + let existing = { + let path = spool_path.clone(); + tokio::task::spawn_blocking(move || { + let entry = SpoolEntry::read_from(&path)?; + validate_spool_entry(&entry, n)?; + Ok::<_, eyre::Report>(entry) + }) + .await? + }; + match existing { + Ok(entry) => { + // The spool is good, but its contract codes live in separate + // files — re-resolve any missing or corrupt ones so the + // worker never wedges on a half-cleaned codes dir. + resolve_missing_codes(client, dirs, &entry.code_hashes).await?; + return Ok(()); + } + Err(e) => { + warn!( + block = n, + spool = %spool_path.display(), + error = %format!("{e:#}"), + "existing spool entry is corrupt — deleting and refetching" + ); + std::fs::remove_file(&spool_path) + .wrap_err_with(|| format!("remove corrupt spool {}", spool_path.display()))?; + } + } + } + + if block_cache.is_none() { + *block_cache = Some(client.get_block(BlockId::number(n), true).await); + } + let block = block_cache.as_ref().expect("just filled"); + let hash = block.header.hash; + // Zero-validation light fetch (from R2 or the witness RPC): no + // elliptic-curve work is spent on the proof we never verify. Full + // witnesses are NOT stored anywhere — when a selected block needs one, it + // is re-fetched on demand. NOTE: the RPC serves witnesses for the full + // history; the R2 bucket is subject to its lifecycle retention — confirm + // the bucket actually holds the target range before pointing an old-era + // scan at `--witness-source r2`, or the 404s will retry forever. + let (light_witness, _mpt_witness) = match r2 { + Some(r2) => r2.get_witness_light(n, hash).await?, + None => client.get_witness_light(n, hash).await, + }; + + let code_hashes = stateless_core::collect_code_hashes(&light_witness.kvs); + resolve_missing_codes(client, dirs, &code_hashes).await?; + + let entry = SpoolEntry { block_json: serde_json::to_vec(block)?, light_witness, code_hashes }; + let path = spool_path.clone(); + tokio::task::spawn_blocking(move || entry.write_to(&path)).await??; + Ok(()) +} + +/// Mirror of the worker's own requirements on a spool entry (see +/// `worker::process_block`): the inner block JSON must parse and carry the +/// expected block number. The bincode envelope decoding alone would pass a +/// spool whose opaque `block_json` bytes are damaged — and the worker would +/// then fail-stop the run on it, on every restart. +fn validate_spool_entry(entry: &SpoolEntry, block: u64) -> Result<()> { + let parsed: alloy_rpc_types_eth::Block = + serde_json::from_slice(&entry.block_json).wrap_err("spool block_json does not parse")?; + ensure!( + parsed.header.inner.number == block, + "spool holds block {}, expected {block}", + parsed.header.inner.number + ); + Ok(()) +} + +/// Fetches and persists any of `code_hashes` not already in the codes dir — +/// where "in" means present AND content-valid: the files are content- +/// addressed, so anything whose keccak doesn't match its name (truncated by +/// a pre-fsync crash, damaged media) is deleted and refetched. Without this, +/// a corrupt code file wedges the run across restarts: the worker replays +/// wrong bytes, diverges, and the judge fail-stops — forever. +async fn resolve_missing_codes( + client: &RpcClient, + dirs: &DataDir, + code_hashes: &[B256], +) -> Result<()> { + let mut missing: Vec = Vec::new(); + for h in code_hashes { + if !code_file_is_valid(&dirs.code_file(h), h) { + missing.push(*h); + } + } + if !missing.is_empty() { + let codes = client + .get_codes(&missing, true) + .await + .map_err(|e| eyre::eyre!("fetch {} bytecodes: {e}", missing.len()))?; + for (code_hash, bytecode) in codes { + write_atomic(&dirs.code_file(&code_hash), &bytecode.original_bytes())?; + } + } + Ok(()) +} + +/// Returns whether `path` holds exactly the bytes hashing to `hash` +/// (content-addressed check, same keccak the RPC fetch verifies). A present- +/// but-invalid file is deleted so the caller refetches it. +fn code_file_is_valid(path: &std::path::Path, hash: &B256) -> bool { + match std::fs::read(path) { + Ok(bytes) if alloy_primitives::keccak256(&bytes) == *hash => true, + Ok(_) => { + warn!( + code = %format!("{hash:x}"), + path = %path.display(), + "content-addressed code file fails its hash — deleting and refetching" + ); + let _ = std::fs::remove_file(path); + false + } + Err(_) => false, + } +} + +/// Owns one resident worker child. A block is NEVER skipped: worker crashes +/// respawn the child and retry the same block, indefinitely; long-running +/// blocks are only warned about (see `slow_block_warn_secs`), never killed. +async fn worker_manager( + id: usize, + rx: kanal::AsyncReceiver, + tx: tokio::sync::mpsc::Sender, + dirs: Arc, + args: BackfillArgs, + llvm_profdata: PathBuf, +) { + let mut worker: Option = None; + let warn_after = Duration::from_secs(args.slow_block_warn_secs.max(1)); + + while let Ok(n) = rx.recv().await { + let req = WorkerRequest { block: n, spool: dirs.spool_entry(n) }; + let mut attempt = 0u64; + let resp = loop { + if worker.is_none() { + match WorkerHandle::spawn(&args, &dirs, &llvm_profdata) { + Ok(w) => worker = Some(w), + Err(e) => { + warn!(worker = id, error = %format!("{e:#}"), "spawn worker failed; retrying in 1s"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + } + } + let w = worker.as_mut().expect("just spawned"); + match w.round_trip(&req, warn_after, id).await { + Ok(resp) => break resp, + Err(e) => { + attempt += 1; + warn!( + worker = id, + block = n, + attempt, + error = %format!("{e:#}"), + "worker died mid-block; respawning and retrying same block" + ); + // Escalate a repeating crash on ONE block: by policy it is + // retried forever, but an operator must be able to find + // the wedge from the error log alone. + if attempt.is_multiple_of(10) { + tracing::error!( + worker = id, + block = n, + attempt, + spool = %dirs.spool_entry(n).display(), + "block has crashed the worker {attempt} times — wedged by policy \ + (blocks are never skipped); this needs operator attention" + ); + } + if let Some(mut dead) = worker.take() { + dead.kill().await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + }; + if tx.send(resp).await.is_err() { + return; // judge gone (run aborting) + } + } +} + +struct WorkerHandle { + child: Child, + stdin: tokio::process::ChildStdin, + stdout: tokio::io::Lines>, +} + +impl WorkerHandle { + fn spawn(args: &BackfillArgs, dirs: &DataDir, llvm_profdata: &PathBuf) -> Result { + let exe = std::env::current_exe()?; + let mut child = tokio::process::Command::new(exe) + .arg("internal-worker") + .arg("--genesis-file") + .arg(&args.genesis_file) + .arg("--codes-dir") + .arg(dirs.codes()) + .arg("--tmp-dir") + .arg(dirs.tmp()) + .arg("--llvm-profdata") + .arg(llvm_profdata) + .arg("--symbol-filter") + .arg(&args.symbol_filter) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .wrap_err("spawn internal-worker")?; + let stdin = child.stdin.take().expect("piped stdin"); + let stdout = child.stdout.take().expect("piped stdout"); + Ok(Self { child, stdin, stdout: tokio::io::BufReader::new(stdout).lines() }) + } + + /// Sends one request and waits for the response with NO deadline: a slow + /// block only produces a periodic warning, never a kill. Errors here mean + /// the child actually died (closed stdout / bad frame), not slowness. + async fn round_trip( + &mut self, + req: &WorkerRequest, + warn_after: Duration, + worker_id: usize, + ) -> Result { + let mut line = serde_json::to_string(req)?; + line.push('\n'); + self.stdin.write_all(line.as_bytes()).await?; + self.stdin.flush().await?; + + let started = Instant::now(); + let mut skipped_lines = 0u64; + let resp: WorkerResponse = loop { + let next = loop { + match tokio::time::timeout(warn_after, self.stdout.next_line()).await { + Err(_still_running) => { + // One factual message either way — a benign library + // print must NOT flip this into "restart the run" + // advice while a legitimately slow block executes. + // The skipped count is the operator's clue: if the + // block NEVER completes, the response frame may have + // been torn by an interleaved (FFI) print — a + // restart retries the block; check worker stderr. + if skipped_lines > 0 { + warn!( + worker = worker_id, + block = req.block, + running_secs = started.elapsed().as_secs(), + skipped_lines, + "block still executing — waiting (blocks are never killed); \ + stdout carried non-protocol lines: if this never completes, \ + the response frame may have been torn by an interleaved print" + ); + } else { + warn!( + worker = worker_id, + block = req.block, + running_secs = started.elapsed().as_secs(), + "block still executing — waiting (blocks are never killed)" + ); + } + } + Ok(next) => break next?, + } + }; + let resp_line = next.ok_or_else(|| eyre::eyre!("worker closed stdout (crashed?)"))?; + // stdout is the protocol channel, but the replay stack underneath + // is not ours: a stray library print must not be treated as + // worker death (killing + retrying would deterministically hit + // the same print, wedging the block forever). Salvage a frame + // embedded anywhere in the line (an unterminated `print!` glues + // its bytes to the front of OUR response); skip pure garbage — + // loudly. + match parse_frame(&resp_line) { + Some(resp) => break resp, + None => { + skipped_lines += 1; + let head: String = resp_line.chars().take(200).collect(); + warn!( + worker = worker_id, + block = req.block, + line = %head, + "ignoring non-protocol line on worker stdout (library print?)" + ); + } + } + }; + ensure!(resp.block == req.block, "response for wrong block"); + Ok(resp) + } + + async fn kill(&mut self) { + let _ = self.child.kill().await; + } +} + +/// Extracts a [`WorkerResponse`] frame from a worker stdout line, tolerating +/// foreign bytes around it: an unterminated library `print!` glues its output +/// to the FRONT of the response on one line, and an interleaved write can +/// trail bytes AFTER it. Tries a prefix-parse from every `{` in the line — +/// garbage JSON cannot satisfy the response's required fields, so a +/// successful parse IS a frame. Returns `None` for a line with no frame. +fn parse_frame(line: &str) -> Option { + use serde::Deserialize; + for (idx, _) in line.match_indices('{') { + let mut de = serde_json::Deserializer::from_str(&line[idx..]); + if let Ok(resp) = WorkerResponse::deserialize(&mut de) { + return Some(resp); + } + } + None +} + +/// Single-consumer ingest: pattern dedup, promotion, persistence, progress. +struct JudgeState<'a> { + store: &'a Store, + dirs: Arc, + counters: HashMap, + next_dense: u32, + patterns: HashMap, + universe: BitSet, + processed: u64, + total: u64, + new_patterns: u64, + started: Instant, + /// Worker wall-clock per successfully replayed block (spool load + replay + /// + profraw + bitmap extraction) — the E3 throughput measurement. + elapsed_ok_ms: ElapsedSampler, + llvm_profdata: PathBuf, +} + +/// Bounded, deterministic reservoir for per-block timings: keeps every +/// `stride`-th sample and doubles the stride when full. A full-history run +/// would otherwise hold one u64 per block (hundreds of MB) just to print one +/// avg/p50/p95 line at the end. +struct ElapsedSampler { + samples: Vec, + stride: u64, + seen: u64, +} + +impl ElapsedSampler { + /// ~8 MB worst case; large enough that percentiles are exact for any + /// single-machine range and statistically indistinguishable beyond it. + const CAP: usize = 1 << 20; + + fn new() -> Self { + Self { samples: Vec::new(), stride: 1, seen: 0 } + } + + fn record(&mut self, elapsed_ms: u64) { + if self.seen.is_multiple_of(self.stride) { + if self.samples.len() >= Self::CAP { + // Decimate: keep every other retained sample, double the stride. + let mut keep = false; + self.samples.retain(|_| { + keep = !keep; + keep + }); + self.stride *= 2; + } + self.samples.push(elapsed_ms); + } + self.seen += 1; + } +} + +impl<'a> JudgeState<'a> { + fn new( + snapshot: crate::store::StoreSnapshot, + store: &'a Store, + dirs: Arc, + total: u64, + llvm_profdata: PathBuf, + ) -> Self { + let mut counters = HashMap::with_capacity(snapshot.counters.len()); + let mut next_dense = 0u32; + for (id, info) in &snapshot.counters { + counters.insert(*id, info.dense); + next_dense = next_dense.max(info.dense + 1); + } + let mut universe = BitSet::new(); + for rec in snapshot.patterns.values() { + universe.union_with(&rec.bitmap); + } + info!( + known_counters = counters.len(), + known_patterns = snapshot.patterns.len(), + universe = universe.count_ones(), + "judge state restored" + ); + Self { + store, + dirs, + counters, + next_dense, + patterns: snapshot.patterns, + universe, + processed: 0, + total, + new_patterns: 0, + started: Instant::now(), + elapsed_ok_ms: ElapsedSampler::new(), + llvm_profdata, + } + } + + /// Fail-stop policy: replay errors and sanity divergences are recorded to + /// the store (spool kept for forensics) and then ABORT the whole run. + /// Rationale: every block has been independently verified to replay + /// cleanly, so any failure here is an infrastructure/chain-spec bug — a + /// gap must never be silently scanned past. The recorded non-Ok status is + /// retried automatically on the next run (see the todo filter). + fn ingest(&mut self, resp: WorkerResponse) -> Result<()> { + self.processed += 1; + + if !resp.ok { + let record = block_record(&resp, BlockStatus::Error, None); + self.store.commit_block(resp.block, &record, &[], None)?; + self.cleanup_tmp(resp.block); + eyre::bail!( + "block {} failed to replay: {} — ABORTING (no block may be skipped; \ + spool kept at {}; a re-run will retry this block)", + resp.block, + resp.error.as_deref().unwrap_or("unknown"), + self.dirs.spool_entry(resp.block).display(), + ); + } + + let sane = resp.gas_ok && resp.receipts_root_ok && resp.logs_bloom_ok; + if !sane { + let record = block_record(&resp, BlockStatus::Divergent, None); + self.store.commit_block(resp.block, &record, &[], None)?; + self.cleanup_tmp(resp.block); + eyre::bail!( + "SANITY FAILURE at block {} (gas_ok={} receipts_root_ok={} logs_bloom_ok={}) — \ + execution diverged from the header; bitmap NOT ingested. ABORTING: this is \ + chain-spec drift or an execution bug, and continuing would leave a silent \ + coverage gap. Spool kept at {}.", + resp.block, + resp.gas_ok, + resp.receipts_root_ok, + resp.logs_bloom_ok, + self.dirs.spool_entry(resp.block).display(), + ); + } + + self.ingest_ok(resp)?; + if self.processed.is_multiple_of(25) || self.processed == self.total { + self.progress_log(); + } + Ok(()) + } + + fn ingest_ok(&mut self, resp: WorkerResponse) -> Result<()> { + self.elapsed_ok_ms.record(resp.elapsed_ms); + // Resolve counter ids → dense indices, registering unseen ids. + let unknown: Vec = + resp.counters.iter().filter(|id| !self.counters.contains_key(id)).copied().collect(); + let mut new_counters: Vec<(u64, CounterInfo)> = Vec::new(); + if !unknown.is_empty() { + let details = read_symbols_tsv(&resp.symbols_tsv)?; + for id in &unknown { + let (index, func_hash, symbol) = details + .get(id) + .cloned() + .ok_or_else(|| eyre::eyre!("counter {id:#x} missing from symbols tsv"))?; + let dense = self.next_dense; + self.next_dense += 1; + self.counters.insert(*id, dense); + new_counters.push((*id, CounterInfo { dense, symbol, func_hash, index })); + } + } + + let bitmap = BitSet::from_indices(resp.counters.iter().map(|id| self.counters[id])); + + // Shared probing walk (worker counters arrive sorted and deduped) — + // the judge and merge MUST key identically; both go through + // `resolve_pattern_slot`. + let (key, occupied) = resolve_pattern_slot(&self.patterns, &resp.counters, &bitmap); + + if occupied { + // Known pattern: merge stats; re-home the representative to the + // lightest block seen (best fixture candidate; the profile is + // keyed by pattern, so nothing on disk moves). + let rec = self.patterns.get_mut(&key).expect("occupied slot"); + rec.hit_count += 1; + // Completion order != block order under parallel workers, so both + // bounds need clamping (merge does the same min/max fold — + // sequential and merged stores must agree on provenance). + rec.first_block = rec.first_block.min(resp.block); + rec.last_block = rec.last_block.max(resp.block); + if resp.elapsed_ms < rec.representative_elapsed_ms { + rec.representative = resp.block; + rec.representative_elapsed_ms = resp.elapsed_ms; + } + } else { + let rec = PatternRecord { + bits: bitmap.count_ones(), + bitmap, + first_block: resp.block, + last_block: resp.block, + hit_count: 1, + representative: resp.block, + representative_elapsed_ms: resp.elapsed_ms, + }; + // Dominated patterns (strict subset of an existing one) can never + // beat their dominator in set cover — record the bitmap for dedup + // and stats, but skip the profile archive (93% of new patterns in + // practice). set-cover excludes them from candidates, so a + // selected block always has an archived profile. + let dominated = self.patterns.values().any(|r| r.dominates(&rec)); + self.universe.union_with(&rec.bitmap); + self.new_patterns += 1; + info!( + block = resp.block, + pattern = %format!("{key:016x}"), + bits = rec.bits, + universe = self.universe.count_ones(), + "NEW coverage pattern" + ); + // Promote. Ordering is the durability invariant: the sparse + // profdata must be ON DISK before the pattern + Ok record are + // committed — a crash in between leaves the block non-Ok, so a + // re-run re-executes it and re-archives. Committing first would + // permanently orphan a non-dominated pattern (block never + // retried, later same-bitmap profraws deleted, `report` fails on + // the missing profile). Archive failure aborts (fail-stop), + // keeping profraw + spool for forensics. + if !dominated { + archive_sparse_profile( + &self.llvm_profdata, + &resp.profraw, + &self.dirs.archived_profile(key), + ) + .wrap_err_with(|| { + format!( + "failed to archive sparse profdata for NEW pattern of block {} \ + (profraw kept at {}) — ABORTING before the pattern is committed", + resp.block, + resp.profraw.display(), + ) + })?; + } + self.patterns.insert(key, rec); + } + + // Shared tail: commit, then clean up (the spool entry goes after the + // commit — a leftover from a crash in between is harmless junk). + let _ = std::fs::remove_file(&resp.profraw); + let record = block_record(&resp, BlockStatus::Ok, Some(key)); + let rec_ref = &self.patterns[&key]; + self.store.commit_block(resp.block, &record, &new_counters, Some((key, rec_ref)))?; + let _ = std::fs::remove_file(self.dirs.spool_entry(resp.block)); + let _ = std::fs::remove_file(&resp.symbols_tsv); + Ok(()) + } + + fn cleanup_tmp(&self, block: u64) { + let _ = std::fs::remove_file(self.dirs.tmp().join(format!("block_{block}.profraw"))); + let _ = + std::fs::remove_file(self.dirs.tmp().join(format!("block_{block}.symbols.tsv.zst"))); + } + + fn progress_log(&self) { + let elapsed = self.started.elapsed().as_secs_f64(); + let rate = self.processed as f64 / elapsed.max(0.001); + let eta_secs = (self.total.saturating_sub(self.processed)) as f64 / rate.max(0.001); + info!( + processed = self.processed, + total = self.total, + patterns = self.patterns.len(), + universe = self.universe.count_ones(), + rate = %format!("{rate:.1}/s"), + eta = %format!("{:.0}s", eta_secs), + "progress" + ); + } + + fn final_summary(&mut self) { + info!( + processed = self.processed, + new_patterns = self.new_patterns, + total_patterns = self.patterns.len(), + universe_counters = self.universe.count_ones(), + elapsed = %format!("{:.1}s", self.started.elapsed().as_secs_f64()), + "backfill finished" + ); + let blocks = self.elapsed_ok_ms.seen; + let sampled = self.elapsed_ok_ms.stride > 1; + let mut samples = std::mem::take(&mut self.elapsed_ok_ms.samples); + if let Some((avg, p50, p95, max)) = elapsed_stats(&mut samples) { + info!( + blocks, + sampled, + avg_ms = %format!("{avg:.0}"), + p50_ms = p50, + p95_ms = p95, + max_ms = max, + "per-block worker time (replay + profraw + bitmap)" + ); + } + } +} + +/// Builds the per-block store record from a worker response. The judge's +/// three commit paths (Ok / Divergent / Error) differ only in status and +/// pattern key: on error paths `resp.gas_used` is 0 and on ok paths +/// `resp.error` is `None`, so one constructor serves all. +fn block_record( + resp: &WorkerResponse, + status: BlockStatus, + pattern_key: Option, +) -> BlockRecord { + BlockRecord { + hash: resp.block_hash, + status, + pattern_key, + gas_used: resp.gas_used, + tx_count: resp.tx_count, + elapsed_ms: resp.elapsed_ms, + error: resp.error.clone(), + } +} + +/// Converts a promoted block's profraw into a small zstd'd sparse profdata: +/// `llvm-profdata merge -sparse` drops every zero-count function (and its +/// name-table entry), which is almost all of them for a single block. +fn archive_sparse_profile( + llvm_profdata: &std::path::Path, + profraw: &std::path::Path, + dest: &std::path::Path, +) -> Result<()> { + let tmp = profraw.with_extension("profdata"); + // llvm-profdata creates its -o output before reading inputs, so the tmp + // file exists even on failure; clean it up on every exit path. + let result = (|| -> Result<()> { + let out = std::process::Command::new(llvm_profdata) + .arg("merge") + .arg("-sparse") + .arg(profraw) + .arg("-o") + .arg(&tmp) + .output() + .wrap_err("spawn llvm-profdata")?; + ensure!( + out.status.success(), + "llvm-profdata merge -sparse failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let bytes = std::fs::read(&tmp)?; + write_atomic(dest, &zstd::encode_all(&bytes[..], 3)?)?; + Ok(()) + })(); + let _ = std::fs::remove_file(&tmp); + result +} + +/// Parses a worker symbols sidecar: `id_hex \t index \t func_hash \t symbol`. +fn read_symbols_tsv(path: &std::path::Path) -> Result> { + let compressed = std::fs::read(path).wrap_err_with(|| format!("read {}", path.display()))?; + let raw = zstd::decode_all(&compressed[..])?; + let text = String::from_utf8(raw)?; + let mut map = HashMap::new(); + for line in text.lines() { + let mut parts = line.splitn(4, '\t'); + let (Some(id), Some(index), Some(func_hash), Some(symbol)) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + continue; + }; + let id = u64::from_str_radix(id, 16)?; + map.insert(id, (index.parse()?, func_hash.to_string(), symbol.to_string())); + } + Ok(map) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{StoreSnapshot, pattern_base_key}; + + fn counter_info(dense: u32) -> CounterInfo { + CounterInfo { dense, symbol: "s".into(), func_hash: "h".into(), index: dense } + } + + fn seeded_pattern(ids: &[u64], denses: &[u32], rep: u64, elapsed: u64) -> (u64, PatternRecord) { + let bitmap = BitSet::from_indices(denses.iter().copied()); + let mut sorted = ids.to_vec(); + sorted.sort_unstable(); + let key = pattern_base_key(&sorted); + let rec = PatternRecord { + bits: bitmap.count_ones(), + bitmap, + first_block: rep, + last_block: rep, + hit_count: 1, + representative: rep, + representative_elapsed_ms: elapsed, + }; + (key, rec) + } + + fn response(block: u64, counters: Vec, elapsed_ms: u64) -> WorkerResponse { + WorkerResponse { + block, + block_hash: B256::repeat_byte(7), + ok: true, + error: None, + gas_ok: true, + receipts_root_ok: true, + logs_bloom_ok: true, + counters, + profraw: PathBuf::from("/nonexistent/test.profraw"), + symbols_tsv: PathBuf::from("/nonexistent/test.tsv.zst"), + elapsed_ms, + tx_count: 1, + gas_used: 21000, + } + } + + /// Judge harness on a real (temp) store, pre-seeded with counters for ids + /// 1/2/3 (dense 0/1/2) and one pattern. Only paths that need no + /// llvm-profdata are exercised (known-pattern dedup, dominated skip); + /// the archive path is covered by the instrumented E2E runs. + fn judge_with<'a>( + store: &'a Store, + dirs: Arc, + patterns: Vec<(u64, PatternRecord)>, + ) -> JudgeState<'a> { + let snapshot = StoreSnapshot { + counters: [(1u64, counter_info(0)), (2, counter_info(1)), (3, counter_info(2))].into(), + patterns: patterns.into_iter().collect(), + blocks: HashMap::new(), + }; + JudgeState::new(snapshot, store, dirs, 10, PathBuf::from("llvm-profdata")) + } + + #[test] + fn known_pattern_dedups_and_rehomes_to_lightest() { + let tmp = tempfile::tempdir().unwrap(); + let dirs = Arc::new(DataDir::new(tmp.path())); + dirs.ensure_layout().unwrap(); + let store = Store::open(&dirs.store_path(), "test-id", Some("f")).unwrap(); + let (key, rec) = seeded_pattern(&[1, 2], &[0, 1], 100, 500); + let mut judge = judge_with(&store, dirs, vec![(key, rec)]); + + // Same bitmap from a LIGHTER block → dedup + re-home. + judge.ingest(response(200, vec![1, 2], 300)).unwrap(); + let rec = &judge.patterns[&key]; + assert_eq!(rec.hit_count, 2); + assert_eq!(rec.representative, 200); + assert_eq!(rec.representative_elapsed_ms, 300); + assert_eq!(rec.last_block, 200); + + // Same bitmap from a HEAVIER block → count only, no re-home. + judge.ingest(response(300, vec![1, 2], 900)).unwrap(); + let rec = &judge.patterns[&key]; + assert_eq!(rec.hit_count, 3); + assert_eq!(rec.representative, 200); + assert_eq!(judge.patterns.len(), 1, "no new pattern was created"); + + // Completion order != block order: an EARLIER block finishing late + // must pull first_block down (merge min-folds the same way — the two + // must agree on provenance). + assert_eq!(rec.first_block, 100); + judge.ingest(response(50, vec![1, 2], 900)).unwrap(); + let rec = &judge.patterns[&key]; + assert_eq!(rec.first_block, 50); + assert_eq!(rec.last_block, 300); + } + + #[test] + fn parse_frame_salvages_embedded_responses() { + let frame = serde_json::to_string(&response(42, vec![1, 2], 100)).unwrap(); + + // Clean frame. + assert_eq!(parse_frame(&frame).unwrap().block, 42); + // Unterminated library print! glued to the front. + assert_eq!(parse_frame(&format!("checking foo... {frame}")).unwrap().block, 42); + // Garbage (even JSON-looking) before AND after. + assert_eq!(parse_frame(&format!("{{\"note\":1}} {frame} trailing")).unwrap().block, 42); + // Pure garbage: no frame. + assert!(parse_frame("progress 5/10 {done}").is_none()); + assert!(parse_frame("{\"block\":7}").is_none(), "missing required fields is not a frame"); + assert!(parse_frame("").is_none()); + } + + #[test] + fn spool_validation_rejects_wrong_or_damaged_block_json() { + let entry = |json: &[u8]| SpoolEntry { + block_json: json.to_vec(), + light_witness: stateless_core::LightWitness { + kvs: Default::default(), + levels: Default::default(), + }, + code_hashes: vec![], + }; + + // Damaged inner JSON decodes fine as a bincode Vec but must fail + // validation. + assert!(validate_spool_entry(&entry(b"not json"), 7).is_err()); + + // A real fixture block validates against its own number and is + // rejected for any other. + let fixture_path = std::fs::read_dir("../../test_data/mainnet/blocks") + .expect("fixture dir") + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|e| e == "json")) + .expect("at least one block fixture"); + let fixture = std::fs::read(&fixture_path).expect("fixture"); + let block: alloy_rpc_types_eth::Block = + serde_json::from_slice(&fixture).unwrap(); + let n = block.header.inner.number; + assert!(validate_spool_entry(&entry(&fixture), n).is_ok()); + assert!(validate_spool_entry(&entry(&fixture), n + 1).is_err()); + } + + #[test] + fn corrupt_code_file_is_detected_and_removed() { + let dir = tempfile::tempdir().unwrap(); + let bytes = b"\x60\x80\x60\x40".to_vec(); + let hash = alloy_primitives::keccak256(&bytes); + + let good = dir.path().join("good.bin"); + std::fs::write(&good, &bytes).unwrap(); + assert!(code_file_is_valid(&good, &hash)); + assert!(good.exists()); + + let bad = dir.path().join("bad.bin"); + std::fs::write(&bad, b"truncated").unwrap(); + assert!(!code_file_is_valid(&bad, &hash)); + assert!(!bad.exists(), "invalid content-addressed file must be deleted for refetch"); + + assert!(!code_file_is_valid(&dir.path().join("absent.bin"), &hash)); + } + + #[test] + fn elapsed_sampler_stays_bounded_and_representative() { + let mut s = ElapsedSampler::new(); + let n = (ElapsedSampler::CAP * 3) as u64; + for i in 0..n { + s.record(i); + } + assert_eq!(s.seen, n); + assert!(s.samples.len() <= ElapsedSampler::CAP, "bounded: {}", s.samples.len()); + assert!(s.stride > 1, "must have decimated"); + // Still spans the full range (deterministic stride, no bias to + // either end): percentile estimates stay meaningful. + let (min, max) = (s.samples.iter().min().unwrap(), s.samples.iter().max().unwrap()); + assert!(*min < n / 10, "min {} not near the start", min); + assert!(*max > n - n / 10, "max {} not near the end", max); + } + + #[test] + fn dominated_new_pattern_recorded_without_archive() { + let tmp = tempfile::tempdir().unwrap(); + let dirs = Arc::new(DataDir::new(tmp.path())); + dirs.ensure_layout().unwrap(); + let store = Store::open(&dirs.store_path(), "test-id", Some("f")).unwrap(); + // Seed the dominator {1,2,3}. + let (dom_key, dom_rec) = seeded_pattern(&[1, 2, 3], &[0, 1, 2], 100, 500); + let mut judge = judge_with(&store, dirs.clone(), vec![(dom_key, dom_rec)]); + + // {1,2} is a strict subset → NEW pattern, dominated: bitmap recorded, + // profile NOT archived. + judge.ingest(response(200, vec![1, 2], 300)).unwrap(); + assert_eq!(judge.patterns.len(), 2); + let sub_key = pattern_base_key(&[1, 2]); + assert!(judge.patterns.contains_key(&sub_key)); + assert!( + !dirs.archived_profile(sub_key).exists(), + "dominated pattern must not get an archived profile" + ); + // Universe unchanged: the subset contributed nothing new. + assert_eq!(judge.universe.count_ones(), 3); + + // The store round-trips the newly committed pattern and block record + // (the seeded dominator lived only in the in-memory snapshot). + let snap = judge.store.load().unwrap(); + assert_eq!(snap.patterns.len(), 1); + assert!(snap.patterns.contains_key(&sub_key)); + assert_eq!(snap.blocks[&200].status, BlockStatus::Ok); + assert_eq!(snap.blocks[&200].pattern_key, Some(sub_key)); + } + + #[test] + fn replay_error_and_divergence_fail_stop() { + let tmp = tempfile::tempdir().unwrap(); + let dirs = Arc::new(DataDir::new(tmp.path())); + dirs.ensure_layout().unwrap(); + let store = Store::open(&dirs.store_path(), "test-id", Some("f")).unwrap(); + let mut judge = judge_with(&store, dirs, vec![]); + + let mut bad = response(400, vec![1], 100); + bad.ok = false; + bad.error = Some("boom".into()); + let err = judge.ingest(bad).unwrap_err(); + assert!(err.to_string().contains("ABORTING"), "{err}"); + // The failure is recorded so a re-run retries the block. + let snap = judge.store.load().unwrap(); + assert_eq!(snap.blocks[&400].status, BlockStatus::Error); + + let mut divergent = response(401, vec![1], 100); + divergent.gas_ok = false; + let err = judge.ingest(divergent).unwrap_err(); + assert!(err.to_string().contains("SANITY FAILURE"), "{err}"); + let snap = judge.store.load().unwrap(); + assert_eq!(snap.blocks[&401].status, BlockStatus::Divergent); + } + + /// The keying contract shared with merge: sorted-id hashing, distinct sets + /// → distinct keys (up to 64-bit collisions). + #[test] + fn pattern_key_contract() { + assert_eq!(pattern_base_key(&[1, 2, 3]), pattern_base_key(&[1, 2, 3])); + assert_ne!(pattern_base_key(&[1, 2]), pattern_base_key(&[1, 3])); + assert_ne!(pattern_base_key(&[1]), pattern_base_key(&[1, 2])); + } +} diff --git a/bin/coverage-replayer/src/bitset.rs b/bin/coverage-replayer/src/bitset.rs new file mode 100644 index 00000000..5828075a --- /dev/null +++ b/bin/coverage-replayer/src/bitset.rs @@ -0,0 +1,112 @@ +//! Dense bitset over the (append-only) dense counter-id space. +//! +//! Pattern bitmaps are small (tens of KB) and the id space only grows, so a +//! plain `Vec` beats pulling in a compressed-bitmap dependency. Older +//! bitmaps are simply shorter; all operations treat missing tail words as zero. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BitSet { + words: Vec, +} + +impl BitSet { + pub fn new() -> Self { + Self::default() + } + + pub fn from_indices(indices: impl IntoIterator) -> Self { + let mut s = Self::new(); + for i in indices { + s.insert(i); + } + s + } + + pub fn insert(&mut self, idx: u32) { + let word = (idx / 64) as usize; + if word >= self.words.len() { + self.words.resize(word + 1, 0); + } + self.words[word] |= 1u64 << (idx % 64); + } + + pub fn count_ones(&self) -> u64 { + self.words.iter().map(|w| w.count_ones() as u64).sum() + } + + /// Number of bits set in `self` that are not set in `other`. + pub fn andnot_count(&self, other: &BitSet) -> u64 { + self.words + .iter() + .enumerate() + .map(|(i, w)| { + let o = other.words.get(i).copied().unwrap_or(0); + (w & !o).count_ones() as u64 + }) + .sum() + } + + pub fn union_with(&mut self, other: &BitSet) { + if other.words.len() > self.words.len() { + self.words.resize(other.words.len(), 0); + } + for (i, w) in other.words.iter().enumerate() { + self.words[i] |= w; + } + } + + /// Short-circuits on the first word disproving subset-hood — this is the + /// inner kernel of the O(n²) dominance scans, where the overwhelmingly + /// common answer is "no". + pub fn is_subset_of(&self, other: &BitSet) -> bool { + self.words + .iter() + .enumerate() + .all(|(i, w)| w & !other.words.get(i).copied().unwrap_or(0) == 0) + } + + /// Iterates the indices of all set bits, ascending. + pub fn iter_ones(&self) -> impl Iterator + '_ { + self.words.iter().enumerate().flat_map(|(wi, &word)| { + let base = (wi as u32) * 64; + std::iter::from_fn({ + let mut w = word; + move || { + if w == 0 { + None + } else { + let bit = w.trailing_zeros(); + w &= w - 1; + Some(base + bit) + } + } + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn basic_ops() { + let a = BitSet::from_indices([1, 5, 100, 1000]); + let b = BitSet::from_indices([5, 100]); + assert_eq!(a.count_ones(), 4); + assert!(b.is_subset_of(&a)); + assert!(!a.is_subset_of(&b)); + assert_eq!(a.andnot_count(&b), 2); + + let mut u = b.clone(); + u.union_with(&a); + assert_eq!(u, a); + + // Different trailing-zero lengths still compare equal in behavior. + let c = BitSet::from_indices([1]); + assert_eq!(c.andnot_count(&a), 0); + assert!(c.is_subset_of(&a)); + } +} diff --git a/bin/coverage-replayer/src/inspect.rs b/bin/coverage-replayer/src/inspect.rs new file mode 100644 index 00000000..8f19f1b5 --- /dev/null +++ b/bin/coverage-replayer/src/inspect.rs @@ -0,0 +1,200 @@ +//! Read-only store inspection: block/pattern/counter statistics. +//! +//! Unlike every other subcommand, `inspect` skips the binary-id namespace +//! check so a store produced on another machine/build (e.g. copied from the +//! server) can be analyzed locally. It never writes. + +use std::path::PathBuf; + +use clap::Args; +use eyre::Result; + +use crate::{ + bitset::BitSet, + setcover::select_cover, + spool::DataDir, + store::{BlockStatus, Store, elapsed_stats}, +}; + +#[derive(Args, Debug, Clone)] +pub struct InspectArgs { + /// Root data directory (same as backfill). + #[clap(long, env = "COVERAGE_REPLAYER_DATA_DIR")] + pub data_dir: PathBuf, + /// How many top entries to print in rankings. + #[clap(long, default_value_t = 10)] + pub top: usize, +} + +pub fn run(args: InspectArgs) -> Result<()> { + let dirs = DataDir::new(&args.data_dir); + let (store, binary_id) = Store::open_readonly(&dirs.store_path())?; + let snapshot = store.load()?; + + println!("binary_id: {binary_id}"); + println!(); + + // ---- blocks ---- + let total = snapshot.blocks.len(); + let mut ok = 0usize; + let mut divergent = 0usize; + let mut errors = 0usize; + let mut elapsed: Vec = Vec::with_capacity(total); + let mut txs = 0u64; + let mut gas = 0u128; + for rec in snapshot.blocks.values() { + match rec.status { + BlockStatus::Ok => { + ok += 1; + elapsed.push(rec.elapsed_ms); + txs += rec.tx_count; + gas += rec.gas_used as u128; + } + BlockStatus::Divergent => divergent += 1, + BlockStatus::Error => errors += 1, + } + } + println!("blocks: total={total} ok={ok} divergent={divergent} error={errors}"); + if let Some((avg, p50, p95, max)) = elapsed_stats(&mut elapsed) { + println!("worker elapsed_ms: avg={avg:.0} p50={p50} p95={p95} max={max}"); + println!("txs total={txs} gas total={gas}"); + } + if errors > 0 || divergent > 0 { + println!("quarantined blocks:"); + for (n, rec) in &snapshot.blocks { + if rec.status != BlockStatus::Ok { + println!(" {n}: {:?} {}", rec.status, rec.error.as_deref().unwrap_or("")); + } + } + } + println!(); + + // ---- patterns ---- + let mut universe = BitSet::new(); + for rec in snapshot.patterns.values() { + universe.union_with(&rec.bitmap); + } + let universe_bits = universe.count_ones(); + let mut hits: Vec<(&u64, &crate::store::PatternRecord)> = snapshot.patterns.iter().collect(); + let singletons = hits.iter().filter(|(_, r)| r.hit_count == 1).count(); + let bits: Vec = hits.iter().map(|(_, r)| r.bits).collect(); + let (bits_min, bits_max) = (bits.iter().min().copied(), bits.iter().max().copied()); + let bits_avg = bits.iter().sum::() as f64 / bits.len().max(1) as f64; + println!( + "patterns: {} (singletons={} = {:.1}%) universe={} counters (of {} ever seen)", + hits.len(), + singletons, + 100.0 * singletons as f64 / hits.len().max(1) as f64, + universe_bits, + snapshot.counters.len(), + ); + println!( + "pattern bits: min={} avg={bits_avg:.0} max={}", + bits_min.unwrap_or(0), + bits_max.unwrap_or(0) + ); + + hits.sort_by_key(|(_, r)| std::cmp::Reverse(r.hit_count)); + println!("top {} patterns by hit_count:", args.top); + for (key, rec) in hits.iter().take(args.top) { + println!( + " {key:016x} hits={:<6} bits={:<6} representative={} ({}..={})", + rec.hit_count, rec.bits, rec.representative, rec.first_block, rec.last_block + ); + } + println!(); + + // ---- counter rarity: how fragile is the universe? ---- + // Dense indices are contiguous, so a Vec beats a HashMap here. + let mut coverage_count: Vec = vec![0; snapshot.counters.len()]; + for (_, rec) in &hits { + for dense in rec.bitmap.iter_ones() { + if let Some(c) = coverage_count.get_mut(dense as usize) { + *c += 1; + } + } + } + let rare1 = coverage_count.iter().filter(|&&c| c == 1).count(); + let rare2 = coverage_count.iter().filter(|&&c| c > 0 && c <= 2).count(); + println!( + "counter rarity: covered-by-exactly-1-pattern={rare1} ({:.1}% of universe), <=2 patterns={rare2}", + 100.0 * rare1 as f64 / universe_bits.max(1) as f64 + ); + + // ---- growth curve: patterns & universe by first-seen block ---- + { + let mut by_first: Vec<(&crate::store::PatternRecord, u64)> = + snapshot.patterns.values().map(|r| (r, r.first_block)).collect(); + by_first.sort_by_key(|(_, fb)| *fb); + if let (Some((_, lo)), Some((_, hi))) = (by_first.first(), by_first.last()) { + let (lo, hi) = (*lo, (*hi).max(lo + 1)); + let buckets = 10u64; + let width = (hi - lo).div_ceil(buckets); + println!(); + println!("growth by first-seen block ({buckets} buckets of {width} blocks):"); + let mut cum = BitSet::new(); + let mut idx = 0usize; + for b in 0..buckets { + // The last bucket takes everything left: with `(hi - lo)` an + // exact multiple of the bucket count, `end == hi` and a + // strict `<` would silently drop the patterns first seen at + // `hi` (at least one always exists). + let last = b + 1 == buckets; + let end = lo + width * (b + 1); + let mut new_patterns = 0u64; + while idx < by_first.len() && (last || by_first[idx].1 < end) { + cum.union_with(&by_first[idx].0.bitmap); + new_patterns += 1; + idx += 1; + } + println!( + " ..{:>10}: +{new_patterns:<5} patterns, universe={}", + end.min(hi), + cum.count_ones() + ); + } + } + } + + // ---- set-cover dry run: THE algorithm (select_cover), not a copy — the + // antichain count and the selection preview cannot drift from a real + // `set-cover` run (no incumbents, and no fs side effects here). + { + let outcome = select_cover(&snapshot.patterns, &Default::default()); + println!(); + println!( + "antichain: {} of {} patterns are strictly dominated ({:.1}%) — prunable \ + along with their archived profiles", + outcome.pruned_dominated.len(), + snapshot.patterns.len(), + 100.0 * outcome.pruned_dominated.len() as f64 / snapshot.patterns.len().max(1) as f64 + ); + println!(); + println!("greedy selection preview (matches a real set-cover run, no incumbents):"); + for (key, rep, gain) in &outcome.selected { + println!(" {rep:>12} gain={gain:<6} bits={}", snapshot.patterns[key].bits); + } + println!( + " => {} blocks cover {}/{}", + outcome.selected.len(), + outcome.covered_counters, + outcome.universe_counters + ); + } + + // ---- manifest ---- + let manifest_path = dirs.manifest_path(); + if manifest_path.exists() { + let manifest: crate::setcover::Manifest = + serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?; + println!(); + println!( + "manifest: {} blocks cover {}/{} counters (generated_at_unix={})", + manifest.blocks.len(), + manifest.covered_counters, + manifest.universe_counters, + manifest.generated_at_unix + ); + } + Ok(()) +} diff --git a/bin/coverage-replayer/src/llvm.rs b/bin/coverage-replayer/src/llvm.rs new file mode 100644 index 00000000..833b4e34 --- /dev/null +++ b/bin/coverage-replayer/src/llvm.rs @@ -0,0 +1,219 @@ +//! LLVM tool discovery and `.profraw` → non-zero-counter extraction. +//! +//! Set cover never needs source mapping: the identity of a coverage counter is +//! `(PGO function name, function hash, counter index)`, hashed to a stable u64. +//! With `-Z coverage-options=branch` the branch true/false counters are plain +//! counters too, so these ids are naturally branch-granular. `llvm-cov` is only +//! used by the `report` subcommand. + +use std::{ + hash::Hasher, + path::{Path, PathBuf}, + process::Command, +}; + +use eyre::{Context, Result, ensure}; +use rustc_hash::FxHasher; + +/// A non-zero counter observed in a profraw, with its stable id. +#[derive(Debug, Clone)] +pub struct CounterHit { + pub id: u64, + pub symbol: String, + pub func_hash: String, + pub index: u32, +} + +/// Stable 64-bit id of a counter. FxHasher is seed-free and deterministic +/// across processes, which is all we need (ids live in one binary namespace). +pub fn counter_id(symbol: &str, func_hash: &str, index: u32) -> u64 { + let mut h = FxHasher::default(); + h.write(symbol.as_bytes()); + h.write_u8(0xff); + h.write(func_hash.as_bytes()); + h.write_u32(index); + h.finish() +} + +/// Locates an LLVM tool: explicit override → rustc sysroot → `$PATH`. +pub fn find_tool(name: &str, cli_override: Option<&str>) -> Result { + if let Some(p) = cli_override { + let p = PathBuf::from(p); + ensure!(p.exists(), "{name} override does not exist: {}", p.display()); + return Ok(p); + } + + // rustc --print sysroot → /lib/rustlib//bin/ + if let Ok(out) = Command::new("rustc").args(["--print", "sysroot"]).output() && + out.status.success() + { + let sysroot = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + let rustlib = sysroot.join("lib").join("rustlib"); + if let Ok(entries) = std::fs::read_dir(&rustlib) { + for entry in entries.flatten() { + let candidate = entry.path().join("bin").join(name); + if candidate.is_file() { + return Ok(candidate); + } + } + } + } + + // PATH fallback + if let Ok(out) = Command::new("which").arg(name).output() && + out.status.success() + { + let p = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim()); + if p.is_file() { + return Ok(p); + } + } + + eyre::bail!( + "{name} not found. Install the `llvm-tools` rustup component or pass an explicit path." + ) +} + +/// Runs `llvm-profdata merge --text` on a profraw and returns all non-zero +/// counters whose PGO symbol name contains `symbol_filter`. +pub fn extract_nonzero_counters( + llvm_profdata: &Path, + profraw: &Path, + symbol_filter: &str, +) -> Result> { + let out = Command::new(llvm_profdata) + .arg("merge") + .arg("--text") + .arg(profraw) + .arg("-o") + .arg("-") + .output() + .wrap_err("spawn llvm-profdata")?; + ensure!( + out.status.success(), + "llvm-profdata merge --text failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let text = String::from_utf8_lossy(&out.stdout); + parse_proftext(&text, symbol_filter) +} + +/// Parses `llvm-profdata merge --text` output. +/// +/// Per-function block layout: +/// ```text +/// +/// # Func Hash: +/// +/// # Num Counters: +/// +/// # Counter Values: +/// +/// ... +/// +/// ``` +/// Unknown sections (e.g. MC/DC bitmaps, value profiling) are skipped by the +/// line scanner because they never match the `# Func Hash:` anchor sequence. +pub fn parse_proftext(text: &str, symbol_filter: &str) -> Result> { + let lines: Vec<&str> = text.lines().collect(); + let mut hits = Vec::new(); + let mut i = 0; + while i < lines.len() { + if lines[i].trim_end() == "# Func Hash:" { + // Symbol is the closest preceding non-empty, non-comment line. + let Some(symbol) = lines[..i] + .iter() + .rev() + .map(|l| l.trim()) + .find(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with(':')) + else { + i += 1; + continue; + }; + let func_hash = lines.get(i + 1).map(|l| l.trim()).unwrap_or_default(); + if lines.get(i + 2).map(|l| l.trim_end()) != Some("# Num Counters:") { + i += 1; + continue; + } + let n: usize = lines + .get(i + 3) + .and_then(|l| l.trim().parse().ok()) + .ok_or_else(|| eyre::eyre!("bad Num Counters near line {i}"))?; + if lines.get(i + 4).map(|l| l.trim_end()) != Some("# Counter Values:") { + i += 1; + continue; + } + let keep = symbol.contains(symbol_filter); + for k in 0..n { + let Some(v) = lines.get(i + 5 + k) else { break }; + if keep { + let value: u128 = v.trim().parse().unwrap_or(0); + if value != 0 { + let index = k as u32; + hits.push(CounterHit { + id: counter_id(symbol, func_hash, index), + symbol: symbol.to_string(), + func_hash: func_hash.to_string(), + index, + }); + } + } + } + i += 5 + n; + } else { + i += 1; + } + } + hits.sort_by_key(|h| h.id); + hits.dedup_by_key(|h| h.id); + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# IR level Instrumentation Flag +:ir +_RNvCsabc_8mega_evm7branchy +# Func Hash: +1234567890 +# Num Counters: +4 +# Counter Values: +10 +0 +3 +0 + +_RNvCsdef_5other3foo +# Func Hash: +42 +# Num Counters: +2 +# Counter Values: +1 +1 +"; + + #[test] + fn parses_and_filters() { + let hits = parse_proftext(SAMPLE, "mega_evm").unwrap(); + assert_eq!(hits.len(), 2); + let indices: Vec = { + let mut v: Vec = hits.iter().map(|h| h.index).collect(); + v.sort(); + v + }; + assert_eq!(indices, vec![0, 2]); + for h in &hits { + assert!(h.symbol.contains("mega_evm")); + assert_eq!(h.id, counter_id(&h.symbol, &h.func_hash, h.index)); + } + + // No filter → both functions counted. + let all = parse_proftext(SAMPLE, "").unwrap(); + assert_eq!(all.len(), 4); + } +} diff --git a/bin/coverage-replayer/src/main.rs b/bin/coverage-replayer/src/main.rs new file mode 100644 index 00000000..be121f17 --- /dev/null +++ b/bin/coverage-replayer/src/main.rs @@ -0,0 +1,74 @@ +//! coverage-replayer: derive the minimal set of mainnet blocks that maximizes +//! mega-evm branch coverage. +//! +//! `backfill` replays a block range under LLVM branch instrumentation: +//! resident worker subprocesses execute each block (reset counters → replay → +//! capture), and a judge dedups the resulting per-block coverage bitmaps into +//! "patterns" in a redb store. `set-cover` computes the minimal block set +//! covering every branch counter ever observed, `report` renders an llvm-cov +//! summary for that set, `inspect` prints store statistics, and `merge` +//! combines per-machine shard stores from a distributed scan. + +mod backfill; +mod bitset; +mod inspect; +mod llvm; +mod merge; +mod profile_rt; +mod proto; +mod r2; +mod report; +mod setcover; +mod spool; +mod store; +mod worker; + +use clap::{Parser, Subcommand}; +use eyre::Result; +use tracing_subscriber::EnvFilter; + +#[derive(Parser, Debug)] +#[clap(name = "coverage-replayer", version, about)] +struct Cli { + #[clap(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Replay a block range, ingest branch-granular coverage bitmaps. + Backfill(backfill::BackfillArgs), + /// Compute the greedy minimal block set from the pattern store. + SetCover(setcover::SetCoverArgs), + /// Print an llvm-cov report for the currently selected set. + Report(report::ReportArgs), + /// Read-only store statistics (works on stores from other builds). + Inspect(inspect::InspectArgs), + /// Merge per-shard stores (disjoint ranges, same build) into one. + Merge(merge::MergeArgs), + /// Internal: resident worker subprocess (spawned by backfill). + #[clap(hide = true)] + InternalWorker(worker::WorkerArgs), +} + +fn main() -> Result<()> { + profile_rt::suppress_default_profile(); + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + match Cli::parse().cmd { + Cmd::InternalWorker(args) => worker::run(args), + Cmd::Backfill(args) => tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(backfill::run(args)), + Cmd::SetCover(args) => setcover::run(args), + Cmd::Report(args) => report::run(args), + Cmd::Inspect(args) => inspect::run(args), + Cmd::Merge(args) => merge::run(args), + } +} diff --git a/bin/coverage-replayer/src/merge.rs b/bin/coverage-replayer/src/merge.rs new file mode 100644 index 00000000..5e143879 --- /dev/null +++ b/bin/coverage-replayer/src/merge.rs @@ -0,0 +1,419 @@ +//! Merge several per-shard stores (produced by different machines scanning +//! disjoint block ranges with the SAME instrumented binary) into one. +//! +//! ## Why a dense remap is required +//! +//! A pattern's bitmap is expressed in *dense* indices — a compact per-store +//! numbering assigned to counters in first-seen order. That order differs +//! between shards, so counter `X` may be dense index 5 on shard A and 8 on +//! shard B. Directly OR-ing bitmaps across shards would therefore be wrong. +//! +//! Two things ARE machine-stable, which makes the merge well-defined: +//! - the 64-bit **counter id** (`(symbol, func_hash, index)` content hash), and +//! - the **pattern key** (`FxHash` of a pattern's sorted counter ids). +//! +//! So we: (1) build a unified id→dense map, (2) for each source pattern remap +//! its bitmap through `source-dense → id → unified-dense`, and (3) fold +//! same-key patterns together (summing hits, keeping the lightest +//! representative). The result is semantically equivalent to a sequential +//! single-machine run (same universe, pattern keys, and stats); only the +//! internal dense numbering may differ (see `merge_snapshots`). +//! +//! Archived profiles are keyed by pattern key (machine-stable), so they are +//! merged by a plain file union — `rsync` every shard's `archive/profiles/` +//! into the output dir; no code handles them here. + +use std::{collections::HashMap, path::PathBuf}; + +use clap::Args; +use eyre::{Context, Result, ensure}; +use tracing::{info, warn}; + +use crate::{ + bitset::BitSet, + spool::DataDir, + store::{ + CounterInfo, PatternRecord, Store, StoreSnapshot, current_binary_id, resolve_pattern_slot, + }, +}; + +#[derive(Args, Debug, Clone)] +pub struct MergeArgs { + /// Output data directory (created fresh; must not already hold a store). + #[clap(long)] + pub out: PathBuf, + /// Two or more shard data directories to merge. + #[clap(long = "shard", required = true, num_args = 1..)] + pub shards: Vec, +} + +pub fn run(args: MergeArgs) -> Result<()> { + ensure!(args.shards.len() >= 2, "merge needs at least two --shard dirs"); + let out_dirs = DataDir::new(&args.out); + out_dirs.ensure_layout()?; + ensure!( + !out_dirs.store_path().exists(), + "output store already exists: {} (merge writes a fresh store)", + out_dirs.store_path().display() + ); + + // The merged store must carry the same binary_id as the shards, and the + // current binary must match it (dense indices are only meaningful for one + // instrumented build). + let expected_id = current_binary_id(); + + let mut shard_snaps = Vec::with_capacity(args.shards.len()); + let mut shard_filter: Option = None; + for (i, shard) in args.shards.iter().enumerate() { + let dirs = DataDir::new(shard); + let (store, binary_id) = Store::open_readonly(&dirs.store_path()) + .wrap_err_with(|| format!("open shard {}", shard.display()))?; + ensure!( + binary_id == expected_id, + "shard {} has binary_id {binary_id}, but this binary is {expected_id}; \ + all shards must be produced by the same instrumented build", + shard.display(), + ); + // All shards must share one symbol filter — it defines the universe. + let filter = store.symbol_filter()?; + if i == 0 { + shard_filter = filter; + } else { + ensure!( + filter == shard_filter, + "shard {} was built with symbol filter {filter:?}, expected {shard_filter:?}; \ + shards with different filters hold incompatible universes", + shard.display(), + ); + } + let snap = store.load()?; + info!( + shard = %shard.display(), + counters = snap.counters.len(), + patterns = snap.patterns.len(), + blocks = snap.blocks.len(), + "loaded shard" + ); + shard_snaps.push((shard.display().to_string(), snap)); + } + + let merged = merge_snapshots(shard_snaps)?; + let mut universe = BitSet::new(); + for rec in merged.patterns.values() { + universe.union_with(&rec.bitmap); + } + info!( + counters = merged.counters.len(), + patterns = merged.patterns.len(), + blocks = merged.blocks.len(), + universe = universe.count_ones(), + "merge complete; writing output store" + ); + + let out_store = Store::open(&out_dirs.store_path(), &expected_id, shard_filter.as_deref())?; + out_store.write_bulk(&merged)?; + info!( + out = %out_dirs.store_path().display(), + "merged store written — rsync each shard's archive/profiles/ into {}", + out_dirs.archive_profiles().display() + ); + Ok(()) +} + +/// Pure core: folds shard snapshots into one, remapping every bitmap through +/// `source-dense → counter id → unified-dense` and merging same-key patterns. +/// +/// Semantically equivalent to a sequential single-store run over the union of +/// ranges and independent of shard order: the universe, the pattern set (by +/// counter-id content), pattern keys, and all merged stats are identical. +/// The *dense numbering* (and therefore raw bitmap/store bytes) is an +/// internal coordinate system and may differ from a sequential run's — dense +/// assignment is deterministic for a given shard order (unseen ids are +/// registered in sorted order per shard), but not canonical. +fn merge_snapshots(shards: Vec<(String, StoreSnapshot)>) -> Result { + let mut id_to_dense: HashMap = HashMap::new(); + let mut counters: HashMap = HashMap::new(); + let mut patterns: HashMap = HashMap::new(); + let mut blocks = HashMap::new(); + + for (label, snap) in shards { + // source dense → counter id, and register unseen ids into the unified + // space. Registration goes in sorted-id order so the merged store is + // reproducible run-to-run (HashMap iteration order is randomized). + let mut src_dense_to_id: HashMap = HashMap::with_capacity(snap.counters.len()); + let mut max_src_dense = 0u32; + let mut shard_ids: Vec = Vec::with_capacity(snap.counters.len()); + for (&id, info) in &snap.counters { + src_dense_to_id.insert(info.dense, id); + max_src_dense = max_src_dense.max(info.dense); + shard_ids.push(id); + } + shard_ids.sort_unstable(); + for id in shard_ids { + if let std::collections::hash_map::Entry::Vacant(e) = id_to_dense.entry(id) { + let dense = counters.len() as u32; + e.insert(dense); + // dense re-pointed below after all ids are known (kept here for + // symbol/func_hash/index provenance). + counters.insert(id, CounterInfo { dense, ..snap.counters[&id].clone() }); + } + } + // Flat per-shard remap tables (src dense → id / unified dense): one + // array index per set bit in the remap loop instead of two hash + // lookups — billions of bits at full-history scale. + let mut flat_id: Vec> = vec![None; max_src_dense as usize + 1]; + let mut flat_unified: Vec = vec![0; max_src_dense as usize + 1]; + for (&src, &id) in &src_dense_to_id { + flat_id[src as usize] = Some(id); + flat_unified[src as usize] = id_to_dense[&id]; + } + // stored key → merged key, for rewriting the shard's block records: + // a 64-bit collision can land a pattern on a different slot in the + // merged space, and blocks must keep pointing at THEIR pattern. + let mut key_map: HashMap = HashMap::with_capacity(snap.patterns.len()); + for (&stored_key, rec) in &snap.patterns { + let mut remapped = BitSet::new(); + let mut ids: Vec = Vec::with_capacity(rec.bits as usize); + for src_dense in rec.bitmap.iter_ones() { + let id = flat_id.get(src_dense as usize).copied().flatten().ok_or_else(|| { + eyre::eyre!( + "shard {label} pattern {stored_key:016x} references dense {src_dense} \ + with no counter — corrupt store" + ) + })?; + remapped.insert(flat_unified[src_dense as usize]); + ids.push(id); + } + ids.sort_unstable(); + + // Re-key exactly as the judge does — same shared probing walk. + let (key, occupied) = resolve_pattern_slot(&patterns, &ids, &remapped); + key_map.insert(stored_key, key); + if occupied { + let existing = patterns.get_mut(&key).expect("occupied slot"); + existing.hit_count += rec.hit_count; + existing.first_block = existing.first_block.min(rec.first_block); + existing.last_block = existing.last_block.max(rec.last_block); + if rec.representative_elapsed_ms < existing.representative_elapsed_ms { + existing.representative = rec.representative; + existing.representative_elapsed_ms = rec.representative_elapsed_ms; + } + } else { + patterns.insert( + key, + PatternRecord { + bits: remapped.count_ones(), + bitmap: remapped, + first_block: rec.first_block, + last_block: rec.last_block, + hit_count: rec.hit_count, + representative: rec.representative, + representative_elapsed_ms: rec.representative_elapsed_ms, + }, + ); + } + if !occupied && key != stored_key { + warn!( + shard = %label, + stored = %format!("{stored_key:016x}"), + merged = %format!("{key:016x}"), + "pattern re-keyed on merge (64-bit key collision). Its archived profile was \ + written under the OLD key: after the rsync union that filename is either \ + missing or occupied by the colliding pattern's profile — if this pattern \ + gets selected, regenerate its profile from the representative block instead \ + of trusting the file" + ); + } + } + + // Blocks: shards scan disjoint ranges, so a plain union. A duplicate + // (should not happen) carries an identical record; last write wins. + // Pattern references follow their pattern through any re-keying; an + // unknown key (block committed, pattern lost — cannot happen with the + // archive-before-commit ordering) is kept verbatim rather than + // silently detached. + for (num, mut rec) in snap.blocks { + if let Some(pk) = rec.pattern_key { + rec.pattern_key = Some(key_map.get(&pk).copied().unwrap_or(pk)); + } + blocks.insert(num, rec); + } + } + + // Re-point every counter's dense to its final unified index (the clone + // above carried the source dense only for provenance fields). + for (id, info) in counters.iter_mut() { + info.dense = id_to_dense[id]; + } + + Ok(StoreSnapshot { counters, patterns, blocks }) +} + +#[cfg(test)] +mod tests { + use alloy_primitives::B256; + + use super::*; + use crate::store::{BlockRecord, BlockStatus, pattern_base_key}; + + fn info(dense: u32, sym: &str, idx: u32) -> CounterInfo { + CounterInfo { dense, symbol: sym.into(), func_hash: "h".into(), index: idx } + } + + fn pat(bitmap: BitSet, rep: u64, ms: u64, hits: u64) -> PatternRecord { + PatternRecord { + bits: bitmap.count_ones(), + bitmap, + first_block: rep, + last_block: rep, + hit_count: hits, + representative: rep, + representative_elapsed_ms: ms, + } + } + + fn blk(hash_byte: u8, ms: u64) -> BlockRecord { + BlockRecord { + hash: B256::repeat_byte(hash_byte), + status: BlockStatus::Ok, + pattern_key: Some(0), + gas_used: 0, + tx_count: 0, + elapsed_ms: ms, + error: None, + } + } + + /// The load-bearing case: two shards see the SAME three counter ids but in + /// different first-seen (dense) orders, so bitmaps use different local + /// indices. Merge must remap by id, not by raw dense — a naive union would + /// silently corrupt coverage. + #[test] + fn merge_remaps_divergent_dense_orders() { + // ids 100,200,300. Shard A dense order 100→0,200→1,300→2. + let a_counters: HashMap = + [(100, info(0, "a", 0)), (200, info(1, "b", 0)), (300, info(2, "c", 0))].into(); + // Shard A pattern {100,300} = local bits {0,2}. + let a_patterns: HashMap = + [(pattern_base_key(&[100, 300]), pat(BitSet::from_indices([0, 2]), 10, 50, 3))].into(); + let a = StoreSnapshot { + counters: a_counters, + patterns: a_patterns, + blocks: [(10u64, blk(1, 50))].into(), + }; + + // Shard B dense order REVERSED: 300→0,200→1,100→2. + let b_counters: HashMap = + [(300, info(0, "c", 0)), (200, info(1, "b", 0)), (100, info(2, "a", 0))].into(); + // Shard B pattern {100,300}: same ids, local bits {2,0}; plus {200}. + let b_patterns: HashMap = [ + (pattern_base_key(&[100, 300]), pat(BitSet::from_indices([0, 2]), 20, 30, 5)), + (pattern_base_key(&[200]), pat(BitSet::from_indices([1]), 21, 40, 2)), + ] + .into(); + let b = StoreSnapshot { + counters: b_counters, + patterns: b_patterns, + blocks: [(20u64, blk(2, 30)), (21u64, blk(3, 40))].into(), + }; + + let merged = merge_snapshots(vec![("A".into(), a), ("B".into(), b)]).expect("merge"); + + // 3 distinct counters, 2 distinct patterns ({100,300} folded), 3 blocks. + assert_eq!(merged.counters.len(), 3); + assert_eq!(merged.patterns.len(), 2); + assert_eq!(merged.blocks.len(), 3); + + // Universe = all 3 counters. + let mut universe = BitSet::new(); + for r in merged.patterns.values() { + universe.union_with(&r.bitmap); + } + assert_eq!(universe.count_ones(), 3); + + // The {100,300} pattern folded: hits summed, lightest representative + // (B's 30ms block 20) wins over A's 50ms. + let folded = merged.patterns.values().find(|r| r.bits == 2).expect("folded 2-bit pattern"); + assert_eq!(folded.hit_count, 3 + 5); + assert_eq!(folded.representative, 20); + assert_eq!(folded.representative_elapsed_ms, 30); + + // Its remapped bitmap references exactly the unified denses of ids 100 + // and 300 — never 200's. + let d100 = merged.counters[&100].dense; + let d300 = merged.counters[&300].dense; + let d200 = merged.counters[&200].dense; + let bits: Vec = folded.bitmap.iter_ones().collect(); + assert!(bits.contains(&d100) && bits.contains(&d300) && !bits.contains(&d200)); + } + + /// When a pattern lands on a different key in the merged space (source + /// shard had probed it off its base slot), the shard's block records must + /// follow it — otherwise they point at whatever occupies the old key. + #[test] + fn merge_rewrites_block_pattern_keys_on_rekey() { + let counters: HashMap = [(100, info(0, "a", 0))].into(); + // Stored under an arbitrary non-base key, as a collision would force. + let stored_key = 0xDEAD_BEEFu64; + let patterns: HashMap = + [(stored_key, pat(BitSet::from_indices([0]), 10, 50, 1))].into(); + let mut block = blk(1, 50); + block.pattern_key = Some(stored_key); + let a = StoreSnapshot { counters, patterns, blocks: [(10u64, block)].into() }; + let b = StoreSnapshot { + counters: [(200, info(0, "b", 0))].into(), + patterns: [(pattern_base_key(&[200]), pat(BitSet::from_indices([0]), 20, 30, 1))] + .into(), + blocks: [(20u64, blk(2, 30))].into(), + }; + + let merged = merge_snapshots(vec![("A".into(), a), ("B".into(), b)]).expect("merge"); + + // The pattern re-keyed to its base slot in the merged space… + let expected_key = pattern_base_key(&[100]); + assert!(merged.patterns.contains_key(&expected_key)); + assert!(!merged.patterns.contains_key(&stored_key)); + // …and the block record followed it. + assert_eq!(merged.blocks[&10].pattern_key, Some(expected_key)); + // The untouched shard's block reference is unchanged. + assert_eq!(merged.blocks[&20].pattern_key, Some(0)); + } + + /// Merge is order-independent: swapping shard order yields the same + /// universe and the same set of pattern bitmaps (id-canonical). + #[test] + fn merge_is_order_independent() { + let mk = |ids_dense: &[(u64, u32)], pat_ids: &[u64], rep: u64| { + let counters: HashMap = + ids_dense.iter().map(|&(id, d)| (id, info(d, "s", d))).collect(); + let key = { + let mut s: Vec = pat_ids.to_vec(); + s.sort_unstable(); + pattern_base_key(&s) + }; + let bm = BitSet::from_indices(pat_ids.iter().map(|id| counters[id].dense)); + StoreSnapshot { + counters, + patterns: [(key, pat(bm, rep, 10, 1))].into(), + blocks: [(rep, blk(1, 10))].into(), + } + }; + let a = mk(&[(1, 0), (2, 1)], &[1, 2], 100); + let b = mk(&[(2, 0), (3, 1)], &[2, 3], 200); + + let ab = merge_snapshots(vec![("A".into(), a.clone()), ("B".into(), b.clone())]).unwrap(); + let ba = merge_snapshots(vec![("B".into(), b), ("A".into(), a)]).unwrap(); + + let uni = |s: &StoreSnapshot| { + let mut u = BitSet::new(); + for r in s.patterns.values() { + u.union_with(&r.bitmap); + } + u.count_ones() + }; + assert_eq!(uni(&ab), 3); + assert_eq!(uni(&ba), 3); + assert_eq!(ab.patterns.len(), ba.patterns.len()); + assert_eq!(ab.counters.len(), ba.counters.len()); + } +} diff --git a/bin/coverage-replayer/src/profile_rt.rs b/bin/coverage-replayer/src/profile_rt.rs new file mode 100644 index 00000000..399e344c --- /dev/null +++ b/bin/coverage-replayer/src/profile_rt.rs @@ -0,0 +1,75 @@ +//! Thin wrapper around the LLVM profiler runtime that `-C instrument-coverage` +//! links into the binary. +//! +//! The three symbols below are the stable C API of compiler-rt's profiling +//! runtime. They only exist when the binary is compiled with +//! `-C instrument-coverage`, so all call sites are gated behind the `coverage` +//! cargo feature; without it the stubs return an error telling the operator to +//! use an instrumented build. + +use std::path::Path; + +use eyre::Result; + +#[cfg(feature = "coverage")] +mod ffi { + unsafe extern "C" { + pub fn __llvm_profile_reset_counters(); + pub fn __llvm_profile_write_file() -> i32; + pub fn __llvm_profile_set_filename(name: *const std::os::raw::c_char); + } +} + +/// Resets all coverage counters of the current process to zero. +#[cfg(feature = "coverage")] +pub fn reset_counters() { + unsafe { ffi::__llvm_profile_reset_counters() } +} + +#[cfg(not(feature = "coverage"))] +pub fn reset_counters() {} + +/// Writes the current counter values to `path` as a `.profraw` file. +#[cfg(feature = "coverage")] +pub fn write_profraw(path: &Path) -> Result<()> { + let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|e| eyre::eyre!("profraw path contains NUL: {e}"))?; + let rc = unsafe { + ffi::__llvm_profile_set_filename(c_path.as_ptr()); + ffi::__llvm_profile_write_file() + }; + // Point the runtime back at /dev/null so the automatic at-exit write can't + // recreate a per-block profraw the judge may already have deleted. + suppress_default_profile(); + eyre::ensure!(rc == 0, "__llvm_profile_write_file returned {rc}"); + Ok(()) +} + +/// Sends the LLVM runtime's automatic at-exit profile write to /dev/null. +/// Without this every instrumented process (dispatcher, set-cover, report) +/// drops a stray `default_*.profraw` into the current directory on exit. +#[cfg(feature = "coverage")] +pub fn suppress_default_profile() { + static DEV_NULL: &std::ffi::CStr = c"/dev/null"; + unsafe { ffi::__llvm_profile_set_filename(DEV_NULL.as_ptr()) } +} + +#[cfg(not(feature = "coverage"))] +pub fn suppress_default_profile() {} + +#[cfg(not(feature = "coverage"))] +pub fn write_profraw(_path: &Path) -> Result<()> { + eyre::bail!( + "this binary was built without the `coverage` feature; \ + rebuild with RUSTFLAGS=\"-C instrument-coverage -Z coverage-options=branch\" \ + cargo build --profile coverage -p coverage-replayer --features coverage \ + --target \"$(rustc -vV | sed -n 's/host: //p')\" \ + — the explicit --target is required: without it proc-macros are \ + instrumented too and spray default_*.profraw files into the cwd" + ) +} + +/// Whether this binary can capture coverage at all. +pub const fn is_instrumented_build() -> bool { + cfg!(feature = "coverage") +} diff --git a/bin/coverage-replayer/src/proto.rs b/bin/coverage-replayer/src/proto.rs new file mode 100644 index 00000000..ea43ce5f --- /dev/null +++ b/bin/coverage-replayer/src/proto.rs @@ -0,0 +1,43 @@ +//! JSONL protocol between the dispatcher and resident worker subprocesses. +//! +//! One request line in, one response line out. Workers are long-lived and +//! process blocks strictly one at a time (the LLVM counters are process-global, +//! so per-block isolation comes from reset→execute→write within one worker). + +use std::path::PathBuf; + +use alloy_primitives::B256; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerRequest { + /// Block number to replay. + pub block: u64, + /// Path to the SpoolEntry file. + pub spool: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerResponse { + pub block: u64, + /// Block hash (zero when the spool entry could not be read). + pub block_hash: B256, + /// Replay completed without an execution error. + pub ok: bool, + /// Execution error message when `ok == false`. + pub error: Option, + /// Sanity comparison against the block header (only meaningful when `ok`). + pub gas_ok: bool, + pub receipts_root_ok: bool, + pub logs_bloom_ok: bool, + /// Stable 64-bit ids of all non-zero coverage counters (sorted, deduped), + /// restricted to symbols matching the configured filter. + pub counters: Vec, + /// Path of the per-block profraw written by the worker. + pub profraw: PathBuf, + /// Path of the sidecar TSV (zstd) mapping counter ids to symbol details. + pub symbols_tsv: PathBuf, + pub elapsed_ms: u64, + pub tx_count: u64, + pub gas_used: u64, +} diff --git a/bin/coverage-replayer/src/r2.rs b/bin/coverage-replayer/src/r2.rs new file mode 100644 index 00000000..c6c1de39 --- /dev/null +++ b/bin/coverage-replayer/src/r2.rs @@ -0,0 +1,243 @@ +//! Direct-from-R2 witness source for the replayer, on the zero-validation +//! light-decode path. +//! +//! Same wire objects as the validator's `--witness-source r2`: the primary +//! object body is `zstd(bincode-legacy((SaltWitness, MptWitness)))`, keyed by +//! `stateless-r2`'s layout so the read path cannot drift from the uploaders. +//! Unlike the validator, the body is decoded with +//! [`decode_witness_payload_light`] — the replayer never verifies proofs, so +//! no elliptic-curve work is spent. +//! +//! Error handling is deliberately minimal: every failure surfaces as an +//! `eyre` error with full context, and the backfill fetcher's retry-forever +//! loop (WARN + 5s backoff, blocks are never skipped) is the retry policy. +//! That flat 5s cadence is already gentler than a backoff ladder's early +//! rounds, so no internal retry loop is needed here. + +use std::time::Duration; + +use alloy_primitives::B256; +use chrono::Utc; +use eyre::{Context, Result, ensure}; +use reqwest::Client; +use stateless_common::decode_witness_payload_light; +use stateless_core::{LightWitness, withdrawals::MptWitness}; +use stateless_r2::{ + endpoint::parse_endpoint, + keys, + sigv4::{SigV4Signer, encode_uri_path}, +}; + +/// Cap on the response body carried inside error messages (real R2 error +/// bodies are a few hundred bytes of XML; a proxy can return arbitrary HTML). +const MAX_ERROR_BODY_BYTES: usize = 1024; + +/// A CLI/env secret that redacts itself in `Debug` output — `BackfillArgs` +/// derives `Debug`, and the R2 secret access key must never reach a log. +/// (Same hardening as the validator's `RedactedSecret`.) +#[derive(Clone)] +pub struct RedactedSecret(String); + +impl std::str::FromStr for RedactedSecret { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +impl std::fmt::Debug for RedactedSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} + +impl AsRef for RedactedSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Fetches witness objects from an R2 bucket with SigV4-signed GETs and +/// decodes them on the light path. Cloning is cheap (`reqwest::Client` is +/// refcounted; the signer redacts credentials in `Debug`). +#[derive(Clone, Debug)] +pub struct R2LightClient { + http: Client, + signer: SigV4Signer, + /// Endpoint origin (`scheme://host`, no trailing slash). + endpoint: String, + /// SigV4 canonical host (`host[:port]`). + host: String, + bucket: String, +} + +impl R2LightClient { + /// Builds a client from an R2 endpoint origin, bucket, and bucket-scoped + /// S3 credentials. `per_attempt_timeout` bounds each individual GET. + pub fn new( + endpoint: &str, + bucket: String, + access_key_id: String, + secret_access_key: String, + per_attempt_timeout: Duration, + ) -> Result { + let (origin, host) = parse_endpoint(endpoint); + ensure!( + !host.is_empty(), + "invalid R2 endpoint {endpoint:?}: expected a bare scheme://host origin \ + (no path/query), e.g. https://.r2.cloudflarestorage.com" + ); + let http = Client::builder() + .timeout(per_attempt_timeout) + // A SigV4-signed GET can never survive a redirect; surface the 3xx. + .redirect(reqwest::redirect::Policy::none()) + .build() + .wrap_err("build R2 HTTP client")?; + Ok(Self { + http, + signer: SigV4Signer::new(access_key_id, secret_access_key), + endpoint: origin, + host, + bucket, + }) + } + + /// Fetches and light-decodes the witness for `(number, hash)`. + pub async fn get_witness_light( + &self, + number: u64, + hash: B256, + ) -> Result<(LightWitness, MptWitness)> { + let key = keys::block_object_key(number, hash); + let canonical_uri = encode_uri_path(&self.bucket, &key); + let url = format!("{}{}", self.endpoint, canonical_uri); + // Signed-payload mode with an empty body: x-amz-content-sha256 = sha256(""). + let signed = self.signer.sign("GET", &self.host, &canonical_uri, "", &[], b"", Utc::now()); + + let mut request = self.http.get(&url); + for (name, value) in signed { + request = request.header(name, value); + } + let response = request + .send() + .await + .wrap_err_with(|| format!("R2 GET transport failure for block {number} (key {key})"))?; + + let status = response.status(); + if !status.is_success() { + let mut body = response.text().await.unwrap_or_default(); + if body.len() > MAX_ERROR_BODY_BYTES { + let mut end = MAX_ERROR_BODY_BYTES; + while !body.is_char_boundary(end) { + end -= 1; + } + body.truncate(end); + } + eyre::bail!("R2 GET for block {number} (key {key}) returned {status}: {body}"); + } + let bytes = response + .bytes() + .await + .wrap_err_with(|| format!("R2 GET body read failed for block {number} (key {key})"))?; + + // zstd + bincode over a multi-MB witness is CPU-bound; keep it off the runtime. + tokio::task::spawn_blocking(move || decode_witness_payload_light(&bytes)) + .await + .wrap_err("R2 witness light-decode task panicked")? + .wrap_err_with(|| format!("R2 witness for block {number} (key {key}) failed to decode")) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + /// Serves one scripted HTTP/1.1 response per connection on a local port + /// and counts requests (same shape as the validator's r2_witness tests). + async fn mock_r2(responses: Vec<(u16, impl Into>)>) -> (String, Arc) { + let responses: Vec<(u16, Vec)> = + responses.into_iter().map(|(status, body)| (status, body.into())).collect(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let n = counter.fetch_add(1, Ordering::SeqCst); + let (status, body) = &responses[n.min(responses.len() - 1)]; + let mut buf = [0u8; 4096]; + let _ = sock.read(&mut buf).await; + let head = format!( + "HTTP/1.1 {status} X\r\nconnection: close\r\ncontent-length: {}\r\n\r\n", + body.len(), + ); + let _ = sock.write_all(head.as_bytes()).await; + let _ = sock.write_all(body).await; + } + }); + (endpoint, hits) + } + + fn client(endpoint: &str) -> R2LightClient { + R2LightClient::new( + endpoint, + "witness-test".to_string(), + "ak".to_string(), + "sk".to_string(), + Duration::from_secs(5), + ) + .unwrap() + } + + /// Happy path end to end: a fixture witness encoded exactly as the + /// uploader writes it must light-decode from a single GET to the same + /// light parts the full decode yields. + #[tokio::test] + async fn valid_object_light_decodes_end_to_end() { + use stateless_test_utils::fixtures::TestFixtures; + + let fixtures = TestFixtures::mainnet_shared(); + let (_, hash) = + fixtures.paired_blocks().into_iter().next().expect("mainnet fixtures have a witness"); + let salt_witness = fixtures.salt_witnesses[&hash].clone(); + let mpt_witness: MptWitness = fixtures.mpt_witness(&hash); + let (_, payload) = stateless_common::encode_witness_payload(&salt_witness, &mpt_witness) + .expect("fixture witness must encode"); + + let (endpoint, hits) = mock_r2(vec![(200, payload)]).await; + let (light, mpt) = client(&endpoint) + .get_witness_light(1, B256::ZERO) + .await + .expect("valid object must fetch and light-decode"); + assert_eq!(light, LightWitness::from(&salt_witness)); + assert_eq!(mpt, mpt_witness); + assert_eq!(hits.load(Ordering::SeqCst), 1, "one GET for a successful fetch"); + } + + /// Failures surface as errors with the status and key in the message — + /// the backfill retry-forever loop is the retry policy, not this client. + #[tokio::test] + async fn non_success_status_surfaces_with_context() { + let (endpoint, hits) = mock_r2(vec![(404, "NoSuchKey")]).await; + let err = client(&endpoint).get_witness_light(7, B256::ZERO).await.unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("404") && msg.contains("block 7"), "{msg}"); + assert_eq!(hits.load(Ordering::SeqCst), 1, "no internal retry"); + } + + #[tokio::test] + async fn corrupt_body_surfaces_decode_error() { + let (endpoint, _) = mock_r2(vec![(200, "not a zstd witness")]).await; + let err = client(&endpoint).get_witness_light(9, B256::ZERO).await.unwrap_err(); + assert!(format!("{err:#}").contains("failed to decode"), "{err:#}"); + } +} diff --git a/bin/coverage-replayer/src/report.rs b/bin/coverage-replayer/src/report.rs new file mode 100644 index 00000000..c7bb948a --- /dev/null +++ b/bin/coverage-replayer/src/report.rs @@ -0,0 +1,149 @@ +//! Human-readable coverage report for the selected block set. +//! +//! This is the only place llvm-cov runs: merge the archived profraws of the +//! selected representatives and print branch/region/line totals plus the +//! per-file table filtered to the crates of interest. + +use std::{path::PathBuf, process::Command}; + +use clap::Args; +use eyre::{Context, Result, ensure}; +use tracing::info; + +use crate::{llvm, setcover::Manifest, spool::DataDir}; + +#[derive(Args, Debug, Clone)] +pub struct ReportArgs { + /// Root data directory (same as backfill). + #[clap(long, env = "COVERAGE_REPLAYER_DATA_DIR")] + pub data_dir: PathBuf, + /// Manifest to report on (default: /manifest.json). + #[clap(long)] + pub manifest: Option, + /// Source directories passed to llvm-cov as the report scope. Default: + /// auto-detect the mega-evm checkout from ./Cargo.lock. + /// + /// IMPORTANT: restricting the scope is not just focus — reporting over the + /// full covmap crashes llvm-cov (LLVM bug in instantiation-group handling + /// for some dependency files); scoping to mega-evm sources avoids it. + #[clap(long = "source-dir")] + pub source_dirs: Vec, + /// Explicit llvm-profdata path (default: auto-detect). + #[clap(long)] + pub llvm_profdata: Option, + /// Explicit llvm-cov path (default: auto-detect). + #[clap(long)] + pub llvm_cov: Option, +} + +pub fn run(args: ReportArgs) -> Result<()> { + ensure!( + crate::profile_rt::is_instrumented_build(), + "report must run from the instrumented build (its binary embeds the coverage map)" + ); + let dirs = DataDir::new(&args.data_dir); + dirs.ensure_layout()?; + let manifest_path = args.manifest.unwrap_or_else(|| dirs.manifest_path()); + let manifest: Manifest = serde_json::from_str( + &std::fs::read_to_string(&manifest_path) + .wrap_err_with(|| format!("read manifest {}", manifest_path.display()))?, + )?; + ensure!(!manifest.blocks.is_empty(), "manifest has no blocks — run set-cover first"); + + let llvm_profdata = llvm::find_tool("llvm-profdata", args.llvm_profdata.as_deref())?; + let llvm_cov = llvm::find_tool("llvm-cov", args.llvm_cov.as_deref())?; + + // Archived per-pattern profiles are zstd'd sparse profdata; inflate to tmp + // for llvm-profdata (profdata files are valid merge inputs). + let mut profraws = Vec::new(); + for b in &manifest.blocks { + let key = u64::from_str_radix(&b.pattern, 16) + .wrap_err_with(|| format!("bad pattern key {}", b.pattern))?; + let z = dirs.archived_profile(key); + ensure!(z.exists(), "archived profile missing for pattern {}: {}", b.pattern, z.display()); + let raw = zstd::decode_all(&std::fs::read(&z)?[..]) + .wrap_err_with(|| format!("decompress {}", z.display()))?; + let tmp = dirs.tmp().join(format!("report_{}.profdata", b.pattern)); + crate::spool::write_atomic(&tmp, &raw)?; + profraws.push(tmp); + } + + let merged = dirs.tmp().join("selected.profdata"); + let out = Command::new(&llvm_profdata) + .arg("merge") + .arg("-sparse") + .args(&profraws) + .arg("-o") + .arg(&merged) + .output()?; + for p in &profraws { + let _ = std::fs::remove_file(p); + } + ensure!( + out.status.success(), + "llvm-profdata merge failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + + let source_dirs = if args.source_dirs.is_empty() { + let detected = detect_mega_evm_checkout().ok_or_else(|| { + eyre::eyre!( + "could not find the mega-evm checkout for the built-against rev under \ + ~/.cargo/git/checkouts; pass --source-dir explicitly" + ) + })?; + info!(dir = %detected.display(), "auto-detected mega-evm sources"); + vec![detected] + } else { + args.source_dirs.clone() + }; + + let exe = std::env::current_exe()?; + let report = Command::new(&llvm_cov) + .arg("report") + .arg(&exe) + .arg(format!("--instr-profile={}", merged.display())) + .args(&source_dirs) + .output()?; + ensure!( + report.status.success(), + "llvm-cov report failed: {}", + String::from_utf8_lossy(&report.stderr) + ); + + info!( + blocks = manifest.blocks.len(), + universe_counters = manifest.universe_counters, + "coverage report for selected set (branch-granular counters: see manifest)" + ); + println!("{}", String::from_utf8_lossy(&report.stdout)); + println!("selected blocks:"); + for b in &manifest.blocks { + println!( + " {:>10} gain={:<6} bits={:<6} pattern={} {}", + b.number, b.gain, b.bits, b.pattern, b.hash + ); + } + Ok(()) +} + +/// Finds the cargo git checkout of the mega-evm rev this binary was BUILT +/// against (embedded by build.rs) — no runtime Cargo.lock parsing, no cwd +/// dependence, and the rev can never disagree with the instrumented build. +fn detect_mega_evm_checkout() -> Option { + let rev: String = env!("COVERAGE_MEGA_EVM_REV").chars().take(7).collect(); + if rev.len() != 7 { + return None; + } + let home = std::env::var_os("HOME")?; + let checkouts = PathBuf::from(home).join(".cargo").join("git").join("checkouts"); + for entry in std::fs::read_dir(checkouts).ok()?.flatten() { + if entry.file_name().to_string_lossy().starts_with("mega-evm-") { + let candidate = entry.path().join(&rev); + if candidate.is_dir() { + return Some(candidate); + } + } + } + None +} diff --git a/bin/coverage-replayer/src/setcover.rs b/bin/coverage-replayer/src/setcover.rs new file mode 100644 index 00000000..522b12ee --- /dev/null +++ b/bin/coverage-replayer/src/setcover.rs @@ -0,0 +1,395 @@ +//! Greedy set cover over the stored coverage patterns. +//! +//! Completeness contract: the selected set ALWAYS covers the full universe — +//! greedy runs until no candidate adds a counter, and neither the antichain +//! prune (dominated patterns contribute no unique counters) nor the +//! redundancy-elimination pass (only drops picks fully covered by the rest) +//! can reduce coverage. Minimality is best-effort on top of that, never at +//! its expense. +//! +//! Selection is churn-damped: ties are broken in favor of blocks already in +//! the incumbent manifest, then by freshness. A final redundancy-elimination +//! pass drops any selected block whose bitmap is covered by the union of the +//! others. + +use std::{collections::HashSet, path::PathBuf}; + +use clap::Args; +use eyre::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::{ + bitset::BitSet, + spool::DataDir, + store::{Store, current_binary_id, elapsed_stats}, +}; + +#[derive(Args, Debug, Clone)] +pub struct SetCoverArgs { + /// Root data directory (same as backfill). + #[clap(long, env = "COVERAGE_REPLAYER_DATA_DIR")] + pub data_dir: PathBuf, + /// Output manifest path (default: /manifest.json). + #[clap(long)] + pub manifest_out: Option, + /// Previous manifest whose blocks get tie-break preference (churn damping). + #[clap(long)] + pub incumbent_manifest: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Manifest { + pub binary_id: String, + pub generated_at_unix: u64, + pub universe_counters: u64, + pub covered_counters: u64, + pub blocks: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ManifestBlock { + pub number: u64, + pub hash: String, + pub pattern: String, + /// Counters this block newly contributed at its selection step. + pub gain: u64, + /// Total counters this block's pattern covers on its own. + pub bits: u64, +} + +pub fn run(args: SetCoverArgs) -> Result<()> { + let dirs = DataDir::new(&args.data_dir); + // `Store::open` creates a missing store — on a mistyped --data-dir that + // would silently produce a 0-block manifest (and pin the fresh store to + // this binary_id). Require an existing store instead. + ensure!( + dirs.store_path().exists(), + "no store at {} — run backfill first (set-cover never creates one)", + dirs.store_path().display() + ); + let binary_id = current_binary_id(); + // No filter check: set-cover consumes whatever universe the store holds. + let store = Store::open(&dirs.store_path(), &binary_id, None)?; + let snapshot = store.load()?; + + // E3 datapoint: worker wall-clock per successfully replayed block. + { + let mut v: Vec = snapshot + .blocks + .values() + .filter(|b| matches!(b.status, crate::store::BlockStatus::Ok)) + .map(|b| b.elapsed_ms) + .collect(); + if let Some((avg, p50, p95, max)) = elapsed_stats(&mut v) { + info!( + blocks = v.len(), + avg_ms = %format!("{avg:.0}"), + p50_ms = p50, + p95_ms = p95, + max_ms = max, + "per-block worker time (replay + profraw + bitmap)" + ); + } + } + + let incumbents: HashSet = match &args.incumbent_manifest { + Some(path) => { + let manifest: Manifest = serde_json::from_str( + &std::fs::read_to_string(path) + .wrap_err_with(|| format!("read incumbent manifest {}", path.display()))?, + )?; + manifest.blocks.iter().map(|b| b.number).collect() + } + None => HashSet::new(), + }; + + info!( + patterns = snapshot.patterns.len(), + incumbents = incumbents.len(), + "computing greedy set cover" + ); + + let outcome = select_cover(&snapshot.patterns, &incumbents); + // The pruned patterns' archived profiles are dead weight — delete them + // (the fs side effect lives here, outside the pure algorithm core). + for key in &outcome.pruned_dominated { + let _ = std::fs::remove_file(dirs.archived_profile(*key)); + } + info!( + pruned = outcome.pruned_dominated.len(), + antichain = snapshot.patterns.len() - outcome.pruned_dominated.len(), + "dominated patterns excluded (their archived profiles deleted)" + ); + // `selected` no longer contains these (select_cover drops them), so the + // removal set itself is the only place they can be reported from. + for rep in &outcome.redundant_removed { + info!(block = rep, "selected early but redundant after later picks — removed"); + } + + let universe_counters = outcome.universe_counters; + let covered_counters = outcome.covered_counters; + let blocks: Vec = outcome + .selected + .iter() + .map(|(key, rep, gain)| { + let rec = &snapshot.patterns[key]; + let hash = snapshot + .blocks + .get(rep) + .map(|b| format!("{:#x}", b.hash)) + .unwrap_or_else(|| "0x0".into()); + ManifestBlock { + number: *rep, + hash, + pattern: format!("{key:016x}"), + gain: *gain, + bits: rec.bits, + } + }) + .collect(); + + let manifest = Manifest { + binary_id, + generated_at_unix: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + universe_counters, + covered_counters, + blocks, + }; + + let out = args.manifest_out.unwrap_or_else(|| dirs.manifest_path()); + crate::spool::write_atomic(&out, serde_json::to_string_pretty(&manifest)?.as_bytes())?; + info!( + selected = manifest.blocks.len(), + covered = covered_counters, + universe = universe_counters, + out = %out.display(), + "set cover written" + ); + for b in &manifest.blocks { + info!(block = b.number, gain = b.gain, bits = b.bits, "selected"); + } + Ok(()) +} + +/// Result of the pure set-cover algorithm. +pub struct CoverOutcome { + /// The final cover in selection order: `(pattern_key, representative, + /// gain)`. Redundancy-eliminated picks are already removed. + pub selected: Vec<(u64, u64, u64)>, + /// Pattern keys strictly dominated by another pattern (excluded from the + /// candidate pool; their archived profiles are safe to delete). + pub pruned_dominated: Vec, + /// Representatives dropped by the redundancy-elimination pass (for + /// logging; no longer present in `selected`). + pub redundant_removed: std::collections::HashSet, + pub universe_counters: u64, + pub covered_counters: u64, +} + +/// Pure greedy set cover with antichain pruning, incumbent-biased +/// tie-breaking, and a final redundancy-elimination pass. No I/O — the fs +/// side effects (deleting pruned profiles) belong to the caller. +pub fn select_cover( + patterns: &std::collections::HashMap, + incumbents: &HashSet, +) -> CoverOutcome { + let mut universe = BitSet::new(); + for rec in patterns.values() { + universe.union_with(&rec.bitmap); + } + let universe_counters = universe.count_ones(); + + // Antichain prune: a strict subset of another pattern can never improve + // the cover — and if left in, it could win a gain tie-break and select a + // block whose profile was never archived (dominated patterns skip the + // archive at promotion time). + let mut remaining: Vec<(&u64, &crate::store::PatternRecord)> = patterns.iter().collect(); + remaining.sort_by_key(|(_, r)| std::cmp::Reverse(r.bits)); + let mut keep = vec![true; remaining.len()]; + let mut pruned_dominated = Vec::new(); + for i in 0..remaining.len() { + for j in 0..i { + if keep[j] && remaining[j].1.dominates(remaining[i].1) { + keep[i] = false; + pruned_dominated.push(*remaining[i].0); + break; + } + } + } + let mut it = keep.iter(); + remaining.retain(|_| *it.next().unwrap()); + + // Greedy: max gain; ties prefer incumbents (churn damping), then the + // higher block number. + let mut covered = BitSet::new(); + let mut selected: Vec<(u64, u64, u64)> = Vec::new(); + loop { + let mut best: Option<(u64, bool, u64, usize)> = None; // (gain, incumbent, block, idx) + for (idx, (_key, rec)) in remaining.iter().enumerate() { + let gain = rec.bitmap.andnot_count(&covered); + if gain == 0 { + continue; + } + let candidate = + (gain, incumbents.contains(&rec.representative), rec.representative, idx); + if best.is_none_or(|b| (candidate.0, candidate.1, candidate.2) > (b.0, b.1, b.2)) { + best = Some(candidate); + } + } + let Some((gain, _inc, _blk, idx)) = best else { break }; + let (key, rec) = remaining.swap_remove(idx); + covered.union_with(&rec.bitmap); + selected.push((*key, rec.representative, gain)); + } + + // Redundancy elimination: drop picks fully covered by the union of the + // others (an early large pick can become redundant after later picks). + let mut removed: std::collections::HashSet = std::collections::HashSet::new(); + let mut pruned = true; + while pruned { + pruned = false; + for i in 0..selected.len() { + let (key, rep, _) = selected[i]; + if removed.contains(&rep) { + continue; + } + let mut others = BitSet::new(); + for (j, (other_key, other_rep, _)) in selected.iter().enumerate() { + if i != j && !removed.contains(other_rep) { + others.union_with(&patterns[other_key].bitmap); + } + } + if patterns[&key].bitmap.is_subset_of(&others) { + removed.insert(rep); + pruned = true; + break; + } + } + } + + // The cover is final here: drop eliminated picks so every consumer sees + // the true selection (removed reps stay available for logging). + selected.retain(|(_, rep, _)| !removed.contains(rep)); + + CoverOutcome { + selected, + pruned_dominated, + redundant_removed: removed, + universe_counters, + covered_counters: covered.count_ones(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + use crate::store::PatternRecord; + + fn pat(bits: &[u32], rep: u64) -> PatternRecord { + let bitmap = BitSet::from_indices(bits.iter().copied()); + PatternRecord { + bits: bitmap.count_ones(), + bitmap, + first_block: rep, + last_block: rep, + hit_count: 1, + representative: rep, + representative_elapsed_ms: 100, + } + } + + fn cover( + patterns: &HashMap, + incumbents: &[u64], + ) -> (Vec, CoverOutcome) { + let incumbents: HashSet = incumbents.iter().copied().collect(); + let outcome = select_cover(patterns, &incumbents); + let mut blocks: Vec = outcome.selected.iter().map(|(_, rep, _)| *rep).collect(); + blocks.sort_unstable(); + (blocks, outcome) + } + + /// Full coverage is always reached and dominated patterns never selected. + #[test] + fn covers_universe_and_prunes_dominated() { + let patterns: HashMap = [ + (1, pat(&[0, 1, 2, 3], 10)), // dominator + (2, pat(&[0, 1], 20)), // strict subset of 1 → pruned + (3, pat(&[4, 5], 30)), + (4, pat(&[5], 40)), // strict subset of 3 → pruned + ] + .into(); + let (blocks, outcome) = cover(&patterns, &[]); + assert_eq!(blocks, vec![10, 30]); + assert_eq!(outcome.covered_counters, outcome.universe_counters); + let mut pruned = outcome.pruned_dominated.clone(); + pruned.sort_unstable(); + assert_eq!(pruned, vec![2, 4]); + } + + /// Equal-bits patterns with different bitmaps must BOTH survive the prune + /// (the guard is strictly `bits >`, never `>=`). + #[test] + fn equal_bits_distinct_patterns_both_survive() { + let patterns: HashMap = + [(1, pat(&[0, 1], 10)), (2, pat(&[2, 3], 20))].into(); + let (blocks, outcome) = cover(&patterns, &[]); + assert_eq!(blocks, vec![10, 20]); + assert!(outcome.pruned_dominated.is_empty()); + } + + /// On a gain tie, the incumbent block wins (churn damping). + #[test] + fn incumbent_wins_gain_ties() { + // Two disjoint equal-size patterns; both must be picked, but the + // FIRST pick (order) must be the incumbent regardless of block number. + let patterns: HashMap = + [(1, pat(&[0, 1], 10)), (2, pat(&[2, 3], 99))].into(); + let incumbents: HashSet = [10].into(); + let outcome = select_cover(&patterns, &incumbents); + assert_eq!(outcome.selected[0].1, 10, "incumbent must be picked first on a tie"); + + // Without incumbency the higher block number wins the tie. + let outcome = select_cover(&patterns, &HashSet::new()); + assert_eq!(outcome.selected[0].1, 99); + } + + /// The {a,b}+{c} vs {a,b,c} shape: greedy picks the superset first and + /// the smaller earlier patterns are never selected at all. + #[test] + fn superset_pattern_makes_smaller_ones_redundant() { + let patterns: HashMap = [ + (1, pat(&[0, 1], 10)), + (2, pat(&[2], 20)), + (3, pat(&[0, 1, 2], 30)), // dominates 1 and 2 → both pruned + ] + .into(); + let (blocks, _) = cover(&patterns, &[]); + assert_eq!(blocks, vec![30]); + } + + /// Redundancy elimination: a first big pick that later picks fully cover + /// gets removed from the final set. + #[test] + fn redundancy_elimination_drops_covered_first_pick() { + // A = {0..5} (biggest, picked first). B = {0,1,2,6}, C = {3,4,5,7}. + // After B and C are picked (each adds a fresh counter), A ⊆ B∪C. + let patterns: HashMap = [ + (1, pat(&[0, 1, 2, 3, 4, 5], 10)), + (2, pat(&[0, 1, 2, 6], 20)), + (3, pat(&[3, 4, 5, 7], 30)), + ] + .into(); + let (blocks, outcome) = cover(&patterns, &[]); + assert_eq!(blocks, vec![20, 30]); + assert!(outcome.redundant_removed.contains(&10)); + // Coverage is still complete without the removed pick. + assert_eq!(outcome.covered_counters, outcome.universe_counters); + } +} diff --git a/bin/coverage-replayer/src/spool.rs b/bin/coverage-replayer/src/spool.rs new file mode 100644 index 00000000..d3e23eb2 --- /dev/null +++ b/bin/coverage-replayer/src/spool.rs @@ -0,0 +1,285 @@ +//! On-disk spool entries: everything a worker needs to replay one block. +//! +//! Lifecycle: written by the fetcher, consumed by a worker, deleted after +//! judgment — for every block, including new-pattern representatives. Nothing +//! block-sized is retained: the RPC serves blocks and witnesses for the full +//! history, so resweeps and PR payload assembly re-fetch representatives by +//! block number (recorded in the store). The only per-pattern artifact kept +//! is a small sparse profdata for `report`. + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use alloy_primitives::B256; +use eyre::{Context, Result}; +use serde::{Deserialize, Serialize}; +use stateless_core::LightWitness; + +const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); +/// zstd level for spool entries. The witness payload inside is already +/// compressed, and spool files live for minutes — favor speed. +const SPOOL_ZSTD_LEVEL: i32 = 1; + +#[derive(Serialize, Deserialize)] +pub struct SpoolEntry { + /// The RPC block re-serialized as JSON (`Block`), + /// the same shape `test_data/mainnet/blocks/*.json` uses. + pub block_json: Vec, + /// Execution witness (kvs + levels only, fast to decode). + pub light_witness: LightWitness, + /// Contract code hashes this block needs (resolved via the codes dir). + pub code_hashes: Vec, +} + +impl SpoolEntry { + pub fn write_to(&self, path: &Path) -> Result<()> { + let raw = bincode::serde::encode_to_vec(self, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("encode spool entry: {e}"))?; + // Frame checksum (xxhash, ~free): most of the entry is opaque + // high-entropy bytes (block_json, witness kvs) where a media-level + // bit flip would decode "successfully" into wrong data — with the + // checksum, ANY byte corruption fails `read_from`, which the fetcher + // treats as delete-and-refetch. Old checksum-less spool files still + // decode (the flag is per-frame). + let mut encoder = zstd::stream::Encoder::new(Vec::new(), SPOOL_ZSTD_LEVEL)?; + encoder.include_checksum(true)?; + std::io::Write::write_all(&mut encoder, &raw)?; + let compressed = encoder.finish()?; + write_atomic(path, &compressed) + } + + pub fn read_from(path: &Path) -> Result { + let compressed = + fs::read(path).wrap_err_with(|| format!("read spool entry {}", path.display()))?; + let raw = zstd::decode_all(&compressed[..])?; + let (entry, _) = bincode::serde::decode_from_slice(&raw, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("decode spool entry {}: {e}", path.display()))?; + Ok(entry) + } +} + +/// Directory layout inside `--data-dir`. +#[derive(Debug, Clone)] +pub struct DataDir { + pub root: PathBuf, +} + +impl DataDir { + /// Pure path arithmetic — creates nothing. Writers call + /// [`Self::ensure_layout`]; read-only consumers (inspect, merge's shard + /// inputs) must not scaffold empty trees in a mistyped or foreign path. + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + /// Creates the standard subdirectory layout (idempotent). + pub fn ensure_layout(&self) -> Result<()> { + for d in [self.spool(), self.codes(), self.tmp(), self.archive_profiles()] { + fs::create_dir_all(&d)?; + } + Ok(()) + } + + pub fn spool(&self) -> PathBuf { + self.root.join("spool") + } + pub fn codes(&self) -> PathBuf { + self.root.join("codes") + } + pub fn tmp(&self) -> PathBuf { + self.root.join("tmp") + } + pub fn archive_profiles(&self) -> PathBuf { + self.root.join("archive").join("profiles") + } + pub fn store_path(&self) -> PathBuf { + self.root.join("store.redb") + } + pub fn manifest_path(&self) -> PathBuf { + self.root.join("manifest.json") + } + + pub fn spool_entry(&self, block: u64) -> PathBuf { + self.spool().join(format!("{block}.bin")) + } + pub fn code_file(&self, hash: &B256) -> PathBuf { + self.codes().join(format!("{hash:x}.bin")) + } + /// Per-pattern sparse profdata (zstd) — only executed functions survive + /// the `llvm-profdata merge -sparse` conversion, so this is small; raw + /// profraws carry the whole binary's counter array plus an incompressible + /// name table (~2 MB even zstd'd) and are never archived. + /// + /// Keyed by pattern (not block) so re-homing a pattern's representative to + /// a lighter block never moves or orphans its profile — the profile is the + /// same regardless of which block produced it (identical bitmap). + pub fn archived_profile(&self, pattern_key: u64) -> PathBuf { + self.archive_profiles().join(format!("{pattern_key:016x}.profdata.zst")) + } +} + +/// Write via unique tmp file + rename so readers never observe partial files, +/// fsynced so the result survives power loss, not just process crashes. +/// +/// The fsync-before-rename is load-bearing for the judge's archive-before- +/// commit invariant: redb commits are fsynced, so if archived profiles were +/// only in the page cache a power cut could persist the pattern while losing +/// its profile — an orphan no re-run can repair (the block is already Ok). +/// The same ordering keeps spool entries from surviving truncated. +/// +/// The tmp name embeds pid + a counter: concurrent writers of the SAME target +/// (e.g. two fetch tasks resolving one shared contract hash) must not collide +/// on the tmp path — last rename wins and both writers succeed. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + use std::{ + io::Write as _, + sync::atomic::{AtomicU64, Ordering}, + }; + static SEQ: AtomicU64 = AtomicU64::new(0); + let unique = format!( + "{}.{}.{}.tmp", + path.file_name().and_then(|n| n.to_str()).unwrap_or("file"), + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed), + ); + let tmp = path.with_file_name(unique); + let result = (|| -> Result<()> { + let mut f = fs::File::create(&tmp).wrap_err_with(|| format!("create {}", tmp.display()))?; + f.write_all(bytes).wrap_err_with(|| format!("write {}", tmp.display()))?; + f.sync_all().wrap_err_with(|| format!("fsync {}", tmp.display()))?; + drop(f); + fs::rename(&tmp, path).wrap_err_with(|| format!("rename to {}", path.display()))?; + // Make the rename itself durable. Directory fsync is best-effort: + // supported on Linux, may be a no-op/error elsewhere (macOS). + if let Some(parent) = path.parent() && + let Ok(dir) = fs::File::open(parent) + { + let _ = dir.sync_all(); + } + Ok(()) + })(); + if result.is_err() { + // ENOSPC/rename failure: don't leave the tmp file behind. + let _ = fs::remove_file(&tmp); + } + result +} + +/// Removes stale `*.tmp` files left by writers killed mid-`write_atomic` +/// (their unique names are never reused, so they accumulate forever +/// otherwise). Non-recursive. +/// +/// `min_age` guards live writers: a healthy `write_atomic` holds its tmp for +/// milliseconds, so anything older than the threshold is orphaned. Callers +/// must still only sweep after acquiring the store lock (one backfill per +/// data-dir) — the age filter is the second line of defense for processes +/// that do NOT hold the lock (e.g. a concurrent `report` inflating profiles +/// into the shared tmp dir). +pub fn sweep_stale_tmp(dir: &Path, min_age: std::time::Duration) -> usize { + let Ok(entries) = fs::read_dir(dir) else { return 0 }; + let mut removed = 0; + for entry in entries.flatten() { + if !entry.file_name().to_string_lossy().ends_with(".tmp") { + continue; + } + let old_enough = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age >= min_age); + if old_enough && fs::remove_file(entry.path()).is_ok() { + removed += 1; + } + } + removed +} + +/// Loads contract bytecodes for the given hashes from the codes dir. +/// Returns the same `HashMap` flavor `WitnessDatabase.contracts` expects. +pub fn load_contracts( + codes_dir: &Path, + hashes: &[B256], +) -> Result> { + let mut map = + alloy_primitives::map::HashMap::with_capacity_and_hasher(hashes.len(), Default::default()); + for hash in hashes { + let path = codes_dir.join(format!("{hash:x}.bin")); + let bytes = fs::read(&path) + .wrap_err_with(|| format!("missing contract code {}", path.display()))?; + map.insert(*hash, revm::state::Bytecode::new_raw(bytes.into())); + } + Ok(map) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + /// Any single corrupted byte in a spool file must fail `read_from` (the + /// zstd frame checksum) — most of the entry is opaque high-entropy bytes + /// where corruption would otherwise decode into silently wrong data, and + /// the fetcher's delete-and-refetch self-heal keys off this error. + #[test] + fn spool_checksum_rejects_any_byte_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("1.bin"); + let entry = SpoolEntry { + block_json: vec![0xA5; 4096], + light_witness: LightWitness { kvs: Default::default(), levels: Default::default() }, + code_hashes: vec![B256::repeat_byte(3)], + }; + entry.write_to(&path).unwrap(); + assert!(SpoolEntry::read_from(&path).is_ok()); + + let clean = fs::read(&path).unwrap(); + // Flip one bit in the middle of the payload region. + for at in [clean.len() / 2, clean.len() - 8] { + let mut damaged = clean.clone(); + damaged[at] ^= 0x01; + fs::write(&path, &damaged).unwrap(); + assert!(SpoolEntry::read_from(&path).is_err(), "byte {at} corruption must not decode"); + } + } + + #[test] + fn write_atomic_round_trips_and_leaves_no_tmp() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("out.bin"); + write_atomic(&target, b"payload").unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"payload"); + assert_eq!(sweep_stale_tmp(dir.path(), Duration::ZERO), 0, "no tmp litter after success"); + } + + #[test] + fn write_atomic_failure_removes_tmp() { + let dir = tempfile::tempdir().unwrap(); + // A directory at the target path makes the final rename fail. + let target = dir.path().join("occupied"); + fs::create_dir(&target).unwrap(); + assert!(write_atomic(&target, b"x").is_err()); + assert_eq!( + sweep_stale_tmp(dir.path(), Duration::ZERO), + 0, + "failed write must clean its tmp file" + ); + } + + #[test] + fn sweep_removes_only_old_tmp_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("stale.bin.123.0.tmp"), b"junk").unwrap(); + fs::write(dir.path().join("keep.bin"), b"data").unwrap(); + // A generous min_age spares the freshly-written (live-looking) tmp… + assert_eq!(sweep_stale_tmp(dir.path(), Duration::from_secs(3600)), 0); + assert!(dir.path().join("stale.bin.123.0.tmp").exists()); + // …zero age reaps it, leaving non-tmp files alone. + assert_eq!(sweep_stale_tmp(dir.path(), Duration::ZERO), 1); + assert!(dir.path().join("keep.bin").exists()); + assert!(!dir.path().join("stale.bin.123.0.tmp").exists()); + } +} diff --git a/bin/coverage-replayer/src/store.rs b/bin/coverage-replayer/src/store.rs new file mode 100644 index 00000000..3fa2ebc1 --- /dev/null +++ b/bin/coverage-replayer/src/store.rs @@ -0,0 +1,540 @@ +//! redb-backed persistence for the coverage-replayer dispatcher. +//! +//! All coverage data is namespaced by `binary_id` (a fingerprint of the +//! instrumented mega-evm build: locked git rev + toolchain/host, see +//! [`current_binary_id`]) and by the symbol filter: counter ids and dense +//! indices are only meaningful for one instrumented build, and the filter +//! defines which counters exist. On mismatch the store refuses to open. + +use std::{collections::HashMap, path::Path}; + +use alloy_primitives::B256; +use eyre::{Result, ensure}; +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; + +use crate::bitset::BitSet; + +const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); +const COUNTERS: TableDefinition = TableDefinition::new("counters"); +const PATTERNS: TableDefinition = TableDefinition::new("patterns"); +const BLOCKS: TableDefinition = TableDefinition::new("blocks"); + +const BINCODE_CONFIG: bincode::config::Configuration = bincode::config::standard(); +const SCHEMA_VERSION: u32 = 1; + +/// Info about one coverage counter (id → dense index + provenance). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CounterInfo { + pub dense: u32, + pub symbol: String, + pub func_hash: String, + pub index: u32, +} + +/// One distinct coverage bitmap and its representative block. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatternRecord { + pub bitmap: BitSet, + pub bits: u64, + pub first_block: u64, + pub last_block: u64, + pub hit_count: u64, + /// The lightest (min replay time) block seen exhibiting this pattern — the + /// best fixture candidate. Re-homed whenever a lighter block appears. + pub representative: u64, + /// Replay time of `representative`, to decide re-homing. + pub representative_elapsed_ms: u64, +} + +impl PatternRecord { + /// Strict domination: `self` covers everything `other` does plus more. + /// The strictness (`bits >`, never `>=`) is load-bearing — equal-bits + /// distinct patterns must never dominate each other. Single definition + /// shared by the judge's archive-skip and set-cover's antichain prune. + pub fn dominates(&self, other: &Self) -> bool { + self.bits > other.bits && other.bitmap.is_subset_of(&self.bitmap) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BlockStatus { + /// Replayed cleanly, bitmap ingested. + Ok, + /// Executed but header sanity comparison failed — bitmap NOT ingested. + Divergent, + /// Replay failed with an error — bitmap NOT ingested. + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockRecord { + pub hash: B256, + pub status: BlockStatus, + /// Pattern key this block's bitmap deduped into (when `status == Ok`). + pub pattern_key: Option, + pub gas_used: u64, + pub tx_count: u64, + pub elapsed_ms: u64, + pub error: Option, +} + +pub struct Store { + db: Database, +} + +impl Store { + /// Opens (or creates) the store and enforces the coverage namespace: + /// `binary_id` always, plus the symbol filter when the caller supplies one + /// (backfill/merge do — the filter defines which counters exist, so mixing + /// filters in one store would silently blend incompatible universes; + /// read-only consumers pass `None` to skip the filter check). + pub fn open(path: &Path, binary_id: &str, symbol_filter: Option<&str>) -> Result { + let db = Database::create(path)?; + + // Ensure all tables exist, then check/stamp namespace metadata. + let txn = db.begin_write()?; + { + let mut meta = txn.open_table(META)?; + txn.open_table(COUNTERS)?; + txn.open_table(PATTERNS)?; + txn.open_table(BLOCKS)?; + + let existing = meta + .get("binary_id")? + .map(|guard| String::from_utf8_lossy(guard.value()).into_owned()); + match existing { + Some(existing) => { + ensure!( + existing == binary_id, + "store {} belongs to binary_id {existing}, current binary is \ + {binary_id}. The counter namespace is per-build: move the data-dir \ + aside (or start a fresh one) and re-sweep.", + path.display(), + ); + check_schema_version(&meta, path)?; + } + None => { + meta.insert("binary_id", binary_id.as_bytes())?; + meta.insert("schema_version", SCHEMA_VERSION.to_le_bytes().as_slice())?; + } + } + + if let Some(filter) = symbol_filter { + let existing = meta + .get("symbol_filter")? + .map(|guard| String::from_utf8_lossy(guard.value()).into_owned()); + match existing { + Some(existing) => { + ensure!( + existing == filter, + "store {} was built with --symbol-filter {existing:?}, this run \ + uses {filter:?}. The filter defines the counter universe: use a \ + fresh data-dir for a different filter.", + path.display(), + ); + } + None => { + meta.insert("symbol_filter", filter.as_bytes())?; + } + } + } + } + txn.commit()?; + + Ok(Self { db }) + } + + /// Reads the stamped symbol filter, if any. + pub fn symbol_filter(&self) -> Result> { + let txn = self.db.begin_read()?; + let meta = txn.open_table(META)?; + let filter = + meta.get("symbol_filter")?.map(|g| String::from_utf8_lossy(g.value()).into_owned()); + drop(meta); + drop(txn); + Ok(filter) + } + + /// Opens an existing store WITHOUT the binary-id namespace check, for + /// read-only inspection of data produced by another build (e.g. analyzing + /// a store copied from a server). Returns the store and its binary_id. + pub fn open_readonly(path: &Path) -> Result<(Self, String)> { + ensure!(path.exists(), "store {} does not exist", path.display()); + let db = Database::open(path)?; + let store = Self { db }; + let txn = store.db.begin_read()?; + let meta = txn.open_table(META)?; + check_schema_version(&meta, path)?; + let binary_id = meta + .get("binary_id")? + .map(|g| String::from_utf8_lossy(g.value()).into_owned()) + .unwrap_or_else(|| "".into()); + drop(meta); + drop(txn); + Ok((store, binary_id)) + } + + /// Loads the whole dispatcher state into memory (counters, patterns, blocks). + pub fn load(&self) -> Result { + let txn = self.db.begin_read()?; + Ok(StoreSnapshot { + counters: read_table(&txn, COUNTERS)?, + patterns: read_table(&txn, PATTERNS)?, + blocks: read_table(&txn, BLOCKS)?, + }) + } + + /// [`Self::load`] variant for `backfill`: counters and patterns in full + /// (they are the working set and bounded by the universe), but block + /// records only for the range being scanned. The BLOCKS table grows by + /// one row per block ever scanned — a full-history store holds tens of + /// millions of rows, and the judge only needs the current range's + /// statuses for its todo filter. + pub fn load_for_range(&self, blocks: std::ops::RangeInclusive) -> Result { + let txn = self.db.begin_read()?; + let t = txn.open_table(BLOCKS)?; + let mut in_range = HashMap::new(); + for row in t.range(blocks)? { + let (k, v) = row?; + let (value, _): (BlockRecord, _) = + bincode::serde::decode_from_slice(v.value(), BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("decode BlockRecord: {e}"))?; + in_range.insert(k.value(), value); + } + drop(t); + Ok(StoreSnapshot { + counters: read_table(&txn, COUNTERS)?, + patterns: read_table(&txn, PATTERNS)?, + blocks: in_range, + }) + } + + /// Persists one judged block: its record, any new counters, and the + /// created/updated pattern — atomically in one transaction. + pub fn commit_block( + &self, + block: u64, + record: &BlockRecord, + new_counters: &[(u64, CounterInfo)], + pattern: Option<(u64, &PatternRecord)>, + ) -> Result<()> { + let txn = self.db.begin_write()?; + { + let mut t = txn.open_table(BLOCKS)?; + let bytes = bincode::serde::encode_to_vec(record, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("encode BlockRecord: {e}"))?; + t.insert(block, bytes.as_slice())?; + } + if !new_counters.is_empty() { + let mut t = txn.open_table(COUNTERS)?; + for (id, info) in new_counters { + let bytes = bincode::serde::encode_to_vec(info, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("encode CounterInfo: {e}"))?; + t.insert(*id, bytes.as_slice())?; + } + } + if let Some((key, rec)) = pattern { + let mut t = txn.open_table(PATTERNS)?; + let bytes = bincode::serde::encode_to_vec(rec, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("encode PatternRecord: {e}"))?; + t.insert(key, bytes.as_slice())?; + } + txn.commit()?; + Ok(()) + } + + /// Bulk-writes a merged snapshot into a fresh store, in batched + /// transactions. Used by the `merge` subcommand. + pub fn write_bulk(&self, snapshot: &StoreSnapshot) -> Result<()> { + self.write_table(COUNTERS, &snapshot.counters)?; + self.write_table(PATTERNS, &snapshot.patterns)?; + self.write_table(BLOCKS, &snapshot.blocks)?; + Ok(()) + } + + /// Writes one `u64 -> bincode(T)` table in batched transactions. + fn write_table( + &self, + table: TableDefinition, + rows: &HashMap, + ) -> Result<()> { + const BATCH: usize = 100_000; + let rows: Vec<_> = rows.iter().collect(); + for chunk in rows.chunks(BATCH) { + let txn = self.db.begin_write()?; + { + let mut t = txn.open_table(table)?; + for (key, value) in chunk { + let bytes = bincode::serde::encode_to_vec(value, BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("encode table row: {e}"))?; + t.insert(**key, bytes.as_slice())?; + } + } + txn.commit()?; + } + Ok(()) + } +} + +/// Rejects a store whose record encoding predates/postdates this binary — +/// otherwise a format change surfaces as opaque bincode decode errors deep +/// inside `read_table` instead of a clean mismatch message. Stores created +/// before versioning are all schema 1. +fn check_schema_version(meta: &T, path: &Path) -> Result<()> +where + T: redb::ReadableTable<&'static str, &'static [u8]>, +{ + let stored = match meta.get("schema_version")? { + Some(guard) => u32::from_le_bytes( + guard + .value() + .try_into() + .map_err(|_| eyre::eyre!("store {}: malformed schema_version", path.display()))?, + ), + None => 1, + }; + ensure!( + stored == SCHEMA_VERSION, + "store {} has schema v{stored}, this binary reads v{SCHEMA_VERSION} — re-sweep into a \ + fresh data-dir (or use a binary matching the store)", + path.display(), + ); + Ok(()) +} + +/// Reads a whole `u64 -> bincode(T)` table into a map. +fn read_table( + txn: &redb::ReadTransaction, + table: TableDefinition, +) -> Result> { + let t = txn.open_table(table)?; + let mut map = HashMap::new(); + for row in t.iter()? { + let (k, v) = row?; + let (value, _): (T, _) = bincode::serde::decode_from_slice(v.value(), BINCODE_CONFIG) + .map_err(|e| eyre::eyre!("decode table row: {e}"))?; + map.insert(k.value(), value); + } + Ok(map) +} + +/// In-memory image of the store, owned by the judge / set-cover. +#[derive(Clone)] +pub struct StoreSnapshot { + pub counters: HashMap, + pub patterns: HashMap, + pub blocks: HashMap, +} + +/// Linear-probe step for pattern-key collisions (golden ratio). Lives beside +/// [`pattern_base_key`] and [`resolve_pattern_slot`] — the probing walk must +/// stay byte-identical between the judge and `merge`. +pub const PROBE_STEP: u64 = 0x9E37_79B9_7F4A_7C15; + +/// Base pattern key: FxHash64 of the pattern's counter ids in ascending +/// order. The SINGLE keying function shared by the judge (backfill) and +/// `merge` — both must key identically or a merged store diverges from a +/// sequential run. Collisions between differing bitmaps are handled by +/// [`resolve_pattern_slot`]'s linear probing. +pub fn pattern_base_key(sorted_ids: &[u64]) -> u64 { + use std::hash::Hasher; + debug_assert!(sorted_ids.is_sorted()); + let mut h = rustc_hash::FxHasher::default(); + for id in sorted_ids { + h.write_u64(*id); + } + h.finish() +} + +/// Walks the probe chain for `bitmap` starting at [`pattern_base_key`] of its +/// sorted counter ids: returns `(slot_key, occupied)` where `occupied` means +/// the slot already holds this exact bitmap (the caller merges stats into +/// it); otherwise the slot is vacant and the caller inserts. The SINGLE +/// probing walk shared by the judge and `merge`. +pub fn resolve_pattern_slot( + patterns: &HashMap, + sorted_ids: &[u64], + bitmap: &BitSet, +) -> (u64, bool) { + let mut key = pattern_base_key(sorted_ids); + loop { + match patterns.get(&key) { + None => return (key, false), + Some(rec) if rec.bitmap == *bitmap => return (key, true), + Some(_) => key = key.wrapping_add(PROBE_STEP), + } + } +} + +/// Coverage namespace key: a fingerprint of the instrumented mega-evm build, +/// NOT a whole-exe hash. Stays stable across dispatcher/orchestration edits +/// (so the resident mode can continue a store built by `backfill`), and only +/// changes when mega-evm's revision or the toolchain changes — exactly when +/// the counter ids would actually shift. Captured at compile time by build.rs. +pub fn current_binary_id() -> String { + use std::hash::Hasher; + let mega_evm = env!("COVERAGE_MEGA_EVM_REV"); + let rustc = env!("COVERAGE_RUSTC_VERSION"); + let mut h = rustc_hash::FxHasher::default(); + h.write(mega_evm.as_bytes()); + h.write_u8(0xff); + h.write(rustc.as_bytes()); + format!("megaevm:{}:fx{:016x}", &mega_evm[..mega_evm.len().min(12)], h.finish()) +} + +/// Sorted-sample summary for per-block worker times: `(avg, p50, p95, max)`. +/// Returns `None` for an empty sample. One definition for the three log +/// sites (backfill summary, set-cover, inspect). +pub fn elapsed_stats(samples: &mut [u64]) -> Option<(f64, u64, u64, u64)> { + if samples.is_empty() { + return None; + } + samples.sort_unstable(); + let avg = samples.iter().sum::() as f64 / samples.len() as f64; + Some(( + avg, + samples[samples.len() / 2], + samples[(samples.len() * 95 / 100).min(samples.len() - 1)], + samples[samples.len() - 1], + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(bits: &[u32]) -> PatternRecord { + let bitmap = BitSet::from_indices(bits.iter().copied()); + PatternRecord { + bits: bitmap.count_ones(), + bitmap, + first_block: 1, + last_block: 1, + hit_count: 1, + representative: 1, + representative_elapsed_ms: 10, + } + } + + fn block(status: BlockStatus) -> BlockRecord { + BlockRecord { + hash: B256::ZERO, + status, + pattern_key: None, + gas_used: 0, + tx_count: 0, + elapsed_ms: 0, + error: None, + } + } + + /// The collision branch of the shared probing walk — the one path whose + /// judge/merge divergence would silently corrupt merged stores. A + /// different bitmap at the base key must step by exactly `PROBE_STEP`; + /// the same bitmap parked one step out must be found as occupied. + #[test] + fn probe_collision_walks_probe_step() { + let ids = [100u64, 200, 300]; + let base = pattern_base_key(&ids); + let target = rec(&[0, 1, 2]); + + let mut patterns: HashMap = [(base, rec(&[7]))].into(); + assert_eq!( + resolve_pattern_slot(&patterns, &ids, &target.bitmap), + (base.wrapping_add(PROBE_STEP), false), + "occupied base slot with a different bitmap must probe one step" + ); + + patterns.insert(base.wrapping_add(PROBE_STEP), target.clone()); + assert_eq!( + resolve_pattern_slot(&patterns, &ids, &target.bitmap), + (base.wrapping_add(PROBE_STEP), true), + "the same bitmap must be found at its probed slot" + ); + + // A second colliding stranger pushes the walk one more step. + let other = rec(&[3, 4]); + assert_eq!( + resolve_pattern_slot(&patterns, &ids, &other.bitmap), + (base.wrapping_add(PROBE_STEP).wrapping_add(PROBE_STEP), false), + ); + } + + #[test] + fn open_rejects_binary_id_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.redb"); + drop(Store::open(&path, "megaevm:aaa:fx1", None).unwrap()); + let err = Store::open(&path, "megaevm:bbb:fx2", None).err().expect("must fail"); + assert!(err.to_string().contains("belongs to binary_id"), "got: {err}"); + } + + #[test] + fn open_rejects_symbol_filter_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.redb"); + drop(Store::open(&path, "id", Some("mega_evm")).unwrap()); + // Same filter reopens fine; a different one is refused. + drop(Store::open(&path, "id", Some("mega_evm")).unwrap()); + let err = Store::open(&path, "id", Some("revm")).err().expect("must fail"); + assert!(err.to_string().contains("--symbol-filter"), "got: {err}"); + } + + #[test] + fn open_rejects_schema_version_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.redb"); + drop(Store::open(&path, "id", None).unwrap()); + + // Tamper: bump the stored schema version behind the API's back. + { + let db = Database::open(&path).unwrap(); + let txn = db.begin_write().unwrap(); + { + let mut meta = txn.open_table(META).unwrap(); + meta.insert("schema_version", (SCHEMA_VERSION + 1).to_le_bytes().as_slice()) + .unwrap(); + } + txn.commit().unwrap(); + } + + let err = Store::open(&path, "id", None).err().expect("must fail"); + assert!(err.to_string().contains("schema"), "open: {err}"); + let err = Store::open_readonly(&path).err().expect("must fail"); + assert!(err.to_string().contains("schema"), "open_readonly: {err}"); + } + + #[test] + fn load_for_range_limits_blocks_but_not_state() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("store.redb"), "id", None).unwrap(); + let pattern = rec(&[0, 1]); + for n in [5u64, 10, 15, 20] { + store + .commit_block( + n, + &block(BlockStatus::Ok), + &[( + n, + CounterInfo { + dense: n as u32, + symbol: "s".into(), + func_hash: "h".into(), + index: 0, + }, + )], + Some((n, &pattern)), + ) + .unwrap(); + } + + let snap = store.load_for_range(10..=15).unwrap(); + let mut in_range: Vec = snap.blocks.keys().copied().collect(); + in_range.sort_unstable(); + assert_eq!(in_range, vec![10, 15], "blocks limited to the range"); + assert_eq!(snap.counters.len(), 4, "counters always loaded in full"); + assert_eq!(snap.patterns.len(), 4, "patterns always loaded in full"); + assert_eq!(store.load().unwrap().blocks.len(), 4, "full load unaffected"); + } +} diff --git a/bin/coverage-replayer/src/worker.rs b/bin/coverage-replayer/src/worker.rs new file mode 100644 index 00000000..92455fc5 --- /dev/null +++ b/bin/coverage-replayer/src/worker.rs @@ -0,0 +1,165 @@ +//! Resident coverage worker subprocess. +//! +//! Spawned by the dispatcher as `coverage-replayer internal-worker ...`. Reads +//! one JSONL [`WorkerRequest`] per line from stdin, replays the block with +//! per-block counter isolation (reset → execute → write profraw), extracts the +//! non-zero counter ids, and answers with one JSONL [`WorkerResponse`]. +//! +//! The worker deliberately does NOT verify the witness or recompute state +//! roots — correctness is guaranteed by the production stateless validator. +//! It only keeps the free sanity comparison of `gas_used` / `receipts_root` / +//! `logs_bloom` against the block header, which catches chain-spec drift +//! before it can poison the coverage store. + +use std::{ + io::{BufRead, Write as _}, + path::PathBuf, + time::Instant, +}; + +use alloy_rpc_types_eth::Block; +use clap::Args; +use eyre::{Context, Result}; +use op_alloy_rpc_types::Transaction as OpTransaction; +use stateless_core::{ + LightWitnessExecutor, WitnessDatabase, WitnessExternalEnv, chain_spec::ChainSpec, replay_block, +}; + +use crate::{ + llvm, + proto::{WorkerRequest, WorkerResponse}, + spool::{self, SpoolEntry, write_atomic}, +}; + +#[derive(Args, Debug, Clone)] +pub struct WorkerArgs { + /// Genesis JSON path used to reconstruct the ChainSpec. + #[clap(long)] + pub genesis_file: String, + /// Content-addressed contract bytecode directory. + #[clap(long)] + pub codes_dir: PathBuf, + /// Directory for per-block profraw / symbol sidecar files. + #[clap(long)] + pub tmp_dir: PathBuf, + /// Path to llvm-profdata. + #[clap(long)] + pub llvm_profdata: PathBuf, + /// Substring filter on PGO symbol names (crate scope of the coverage universe). + #[clap(long, default_value = "mega_evm")] + pub symbol_filter: String, +} + +/// Entry point of the worker subprocess. Loops until stdin closes. +pub fn run(args: WorkerArgs) -> Result<()> { + let genesis = serde_json::from_str::( + &std::fs::read_to_string(&args.genesis_file) + .wrap_err_with(|| format!("read genesis {}", args.genesis_file))?, + )?; + let chain_spec = ChainSpec::from_genesis(genesis); + + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout().lock(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let req: WorkerRequest = serde_json::from_str(&line) + .map_err(|e| eyre::eyre!("bad worker request {line:?}: {e}"))?; + let resp = process_block(&args, &chain_spec, &req) + .unwrap_or_else(|e| error_response(req.block, format!("{e:#}"))); + serde_json::to_writer(&mut stdout, &resp)?; + stdout.write_all(b"\n")?; + stdout.flush()?; + } + Ok(()) +} + +fn error_response(block: u64, error: String) -> WorkerResponse { + WorkerResponse { + block, + block_hash: alloy_primitives::B256::ZERO, + ok: false, + error: Some(error), + gas_ok: false, + receipts_root_ok: false, + logs_bloom_ok: false, + counters: Vec::new(), + profraw: PathBuf::new(), + symbols_tsv: PathBuf::new(), + elapsed_ms: 0, + tx_count: 0, + gas_used: 0, + } +} + +fn process_block( + args: &WorkerArgs, + chain_spec: &ChainSpec, + req: &WorkerRequest, +) -> Result { + let start = Instant::now(); + + let SpoolEntry { block_json, light_witness, code_hashes, .. } = + SpoolEntry::read_from(&req.spool)?; + let block: Block = + serde_json::from_slice(&block_json).wrap_err("decode block json")?; + let header = &block.header.inner; + eyre::ensure!(header.number == req.block, "spool/request block number mismatch"); + + let contracts = spool::load_contracts(&args.codes_dir, &code_hashes)?; + let ext_env = WitnessExternalEnv::from_light_witness(&light_witness, header.number) + .map_err(|e| eyre::eyre!("env oracle construction: {e}"))?; + let executor = LightWitnessExecutor::from(light_witness); + let db = WitnessDatabase { header, witness: &executor, contracts: &contracts }; + + // Per-block counter isolation: this worker handles one block at a time. + crate::profile_rt::reset_counters(); + let result = replay_block(chain_spec, &block, &db, ext_env, None); + let profraw = args.tmp_dir.join(format!("block_{}.profraw", req.block)); + crate::profile_rt::write_profraw(&profraw)?; + + let output = match result { + Ok((_accounts, output)) => output, + Err(e) => { + // Execution failed — the bitmap would be misleading, drop it. + let _ = std::fs::remove_file(&profraw); + let mut resp = error_response(req.block, format!("replay failed: {e}")); + resp.block_hash = block.header.hash; + return Ok(resp); + } + }; + + let gas_ok = output.gas_used == header.gas_used; + let receipts_root_ok = output.receipts_root == header.receipts_root; + let logs_bloom_ok = output.logs_bloom == header.logs_bloom; + + let hits = llvm::extract_nonzero_counters(&args.llvm_profdata, &profraw, &args.symbol_filter)?; + + // Sidecar with full symbol details, read by the dispatcher only for ids it + // has never seen before (rare after warm-up). + let symbols_tsv = args.tmp_dir.join(format!("block_{}.symbols.tsv.zst", req.block)); + let mut tsv = String::with_capacity(hits.len() * 96); + for h in &hits { + use std::fmt::Write as _; + let _ = writeln!(tsv, "{:016x}\t{}\t{}\t{}", h.id, h.index, h.func_hash, h.symbol); + } + write_atomic(&symbols_tsv, &zstd::encode_all(tsv.as_bytes(), 1)?)?; + + Ok(WorkerResponse { + block: req.block, + block_hash: block.header.hash, + ok: true, + error: None, + gas_ok, + receipts_root_ok, + logs_bloom_ok, + counters: hits.into_iter().map(|h| h.id).collect(), + profraw, + symbols_tsv, + elapsed_ms: start.elapsed().as_millis() as u64, + tx_count: block.transactions.len() as u64, + gas_used: output.gas_used, + }) +} diff --git a/bin/coverage-replayer/tests/replay_fixtures.rs b/bin/coverage-replayer/tests/replay_fixtures.rs new file mode 100644 index 00000000..1251d4f8 --- /dev/null +++ b/bin/coverage-replayer/tests/replay_fixtures.rs @@ -0,0 +1,44 @@ +//! Replays every mainnet fixture block through the exact execution path the +//! coverage worker uses (LightWitness → WitnessDatabase → replay_block) and +//! checks the gas/receipts-root/logs-bloom sanity triple against the header. +//! +//! Runs uninstrumented — it guards the replay glue in normal CI; coverage +//! capture itself is exercised by the instrumented E2E runs. + +use stateless_core::{ + LightWitness, LightWitnessExecutor, WitnessDatabase, WitnessExternalEnv, chain_spec::ChainSpec, + replay_block, +}; +use stateless_test_utils::fixtures::TestFixtures; + +#[test] +fn replays_mainnet_fixtures_with_header_sanity() { + let fixtures = TestFixtures::mainnet_shared(); + let chain_spec = ChainSpec::from_genesis(fixtures.load_genesis().expect("genesis")); + // WitnessDatabase expects the alloy HashMap flavor; rebuild once. + let contracts: alloy_primitives::map::HashMap<_, _> = + fixtures.contracts.iter().map(|(k, v)| (*k, v.clone())).collect(); + + let paired = fixtures.paired_blocks(); + assert!(!paired.is_empty(), "no paired fixture blocks found"); + + for (number, hash) in paired { + let block = &fixtures.blocks[&hash]; + let header = &block.header.inner; + let light = LightWitness::from(&fixtures.salt_witnesses[&hash]); + let ext_env = WitnessExternalEnv::from_light_witness(&light, number) + .unwrap_or_else(|e| panic!("env oracle for block {number}: {e}")); + let executor = LightWitnessExecutor::from(light); + let db = WitnessDatabase { header, witness: &executor, contracts: &contracts }; + + let (_accounts, out) = replay_block(&chain_spec, block, &db, ext_env, None) + .unwrap_or_else(|e| panic!("replay block {number}: {e}")); + + assert_eq!(out.gas_used, header.gas_used, "gas mismatch at block {number}"); + assert_eq!( + out.receipts_root, header.receipts_root, + "receipts root mismatch at block {number}" + ); + assert_eq!(out.logs_bloom, header.logs_bloom, "logs bloom mismatch at block {number}"); + } +} diff --git a/bin/debug-trace-server/Cargo.toml b/bin/debug-trace-server/Cargo.toml index 7cd5ebe5..d0346a8f 100644 --- a/bin/debug-trace-server/Cargo.toml +++ b/bin/debug-trace-server/Cargo.toml @@ -19,7 +19,6 @@ alloy-rpc-types-trace.workspace = true # mega mega-evm.workspace = true -salt.workspace = true # op op-alloy-network.workspace = true @@ -69,7 +68,7 @@ dotenvy.workspace = true http-body-util.workspace = true # HTTP client for integration tests -reqwest.workspace = true +reqwest = { workspace = true, features = ["blocking", "json"] } tempfile.workspace = true # stateless diff --git a/bin/debug-trace-server/src/chain_sync.rs b/bin/debug-trace-server/src/chain_sync.rs index a1caf2ff..1e258a0f 100644 --- a/bin/debug-trace-server/src/chain_sync.rs +++ b/bin/debug-trace-server/src/chain_sync.rs @@ -19,8 +19,11 @@ use stateless_core::{ use crate::{metrics, response_cache::ResponseCache, server_db::BlockStore}; -/// Fetcher for the trace server: fetches blocks + witnesses, discards MPT witness, -/// converts SALT witness to [`LightWitness`]. +/// Fetcher for the trace server: fetches blocks + witnesses, discards MPT witness. +/// +/// Witnesses go through the zero-validation light decode (`get_witness_light`): +/// the server never verifies the proof, so the full decode's per-point +/// elliptic-curve work (~1 core·s on large witnesses) bought nothing. pub struct TraceFetcher { pub rpc_client: Arc, } @@ -35,11 +38,11 @@ impl BlockFetcher for TraceFetcher { // fetch instead of serializing all three round trips. let block_hash = self.rpc_client.get_block_hash(block_number).await; let (witness_res, block_res) = tokio::join!( - self.rpc_client.get_witness(block_number, block_hash), + self.rpc_client.get_witness_light(block_number, block_hash), self.rpc_client.get_block(BlockId::Number(block_number.into()), true), ); - let (salt, _mpt) = witness_res; - Ok((block_res, LightWitness::from(&salt))) + let (light, _mpt) = witness_res; + Ok((block_res, light)) } async fn latest_block_number(&self) -> Result { diff --git a/bin/debug-trace-server/src/data_provider.rs b/bin/debug-trace-server/src/data_provider.rs index b5d3bf27..ed5f8aa5 100644 --- a/bin/debug-trace-server/src/data_provider.rs +++ b/bin/debug-trace-server/src/data_provider.rs @@ -33,8 +33,7 @@ use dashmap::DashMap; use futures::{FutureExt, future::Shared}; use op_alloy_rpc_types::Transaction; use revm::state::Bytecode; -use salt::SaltWitness; -use stateless_common::{CodeFetchError, RpcClient, RpcDeadlineExceeded, estimate_witness_size}; +use stateless_common::{CodeFetchError, RpcClient, RpcDeadlineExceeded, WitnessSizeBreakdown}; use stateless_core::{ ContractStore, LightWitness, StoreResult, db::StoreError, withdrawals::MptWitness, }; @@ -592,7 +591,7 @@ fn shared_to_result( /// 2. Fetch witness and full block in parallel, each subject to the shared `deadline`. The witness /// stage also gets a sub-deadline: `min(deadline, now + witness_timeout)`, tightened further for /// old blocks (see `witness_deadline_for`). -/// 3. Convert SaltWitness to LightWitness. +/// 3. The witness arrives as a `LightWitness` already (zero-validation light decode). /// 4. Extract code hashes from witness and fetch contract bytecodes (shares `deadline`). async fn do_fetch_block_data( rpc_client: Arc, @@ -637,15 +636,11 @@ async fn do_fetch_block_data( let (block_result, block_elapsed) = block_timed; let fetch_witness_ms = witness_elapsed.as_millis(); - let (salt_witness, _mpt_witness) = witness_result?; + // Step 3: the light decode already produced a LightWitness — no conversion. + let (witness, _mpt_witness) = witness_result?; let block = block_result?; let fetch_full_block_ms = block_elapsed.as_millis(); - // Step 3: Convert SaltWitness to LightWitness. - let start = Instant::now(); - let witness = LightWitness::from(&salt_witness); - let convert_witness_ms = start.elapsed().as_millis(); - // Step 4: Extract code hashes and fetch contracts. let start = Instant::now(); let code_hashes = crate::tracing_executor::extract_code_hashes(&witness); @@ -658,7 +653,6 @@ async fn do_fetch_block_data( if fetch_header_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_witness_ms >= SLOW_STAGE_THRESHOLD_MS || - convert_witness_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_full_block_ms >= SLOW_STAGE_THRESHOLD_MS || fetch_contracts_ms >= SLOW_STAGE_THRESHOLD_MS { @@ -669,7 +663,6 @@ async fn do_fetch_block_data( num_contracts, fetch_header_ms = fetch_header_ms as u64, fetch_witness_ms = fetch_witness_ms as u64, - convert_witness_ms = convert_witness_ms as u64, fetch_full_block_ms = fetch_full_block_ms as u64, fetch_contracts_ms = fetch_contracts_ms as u64, total_ms = total_ms as u64, @@ -718,19 +711,25 @@ fn witness_deadline_for( /// Fetches witness data via the deadline-aware `RpcClient` API. The `deadline` is the /// witness stage's effective deadline (see [`witness_deadline_for`]). +/// +/// Uses the zero-validation light decode: the trace server never verifies the +/// witness proof, so the full decode's per-point elliptic-curve work (~110ms +/// wall / ~1 core·s on large witnesses) bought nothing. The recorded size is +/// the light lower bound (excludes the never-decoded parent commitments). async fn fetch_witness( rpc_client: &RpcClient, block_number: u64, block_hash: B256, deadline: Instant, -) -> DataProviderResult<(SaltWitness, MptWitness)> { +) -> DataProviderResult<(LightWitness, MptWitness)> { let wg_metrics = WitnessSourceMetrics::new_for_source("witness_generator"); let start = Instant::now(); - match rpc_client.get_witness_with_deadline(block_number, block_hash, Some(deadline)).await { + match rpc_client.get_witness_light_with_deadline(block_number, block_hash, Some(deadline)).await + { Ok(w) => { wg_metrics.record_request(true, start.elapsed().as_secs_f64()); - wg_metrics.record_size(estimate_witness_size(&w.0, &w.1)); + wg_metrics.record_size(WitnessSizeBreakdown::new_light(&w.0, &w.1).total()); DataSourceMetrics::new_for_source("witness_generator").record(); Ok(w) } diff --git a/bin/debug-trace-server/src/tracing_executor.rs b/bin/debug-trace-server/src/tracing_executor.rs index 9fb8ba6f..f8c56055 100644 --- a/bin/debug-trace-server/src/tracing_executor.rs +++ b/bin/debug-trace-server/src/tracing_executor.rs @@ -56,7 +56,6 @@ use revm_inspectors::tracing::{ }; use stateless_core::{ chain_spec::ChainSpec, - data_types::iter_code_hashes, evm_database::{WitnessDatabase, WitnessExternalEnv}, executor::{ValidationError, create_evm_env}, light_witness::{LightWitness, LightWitnessExecutor}, @@ -65,10 +64,7 @@ use tracing::{instrument, trace, warn}; /// Returns distinct contract code hashes referenced by the witness, sorted for stable ordering. pub fn extract_code_hashes(witness: &LightWitness) -> Vec { - let mut code_hashes: Vec = iter_code_hashes(&witness.kvs).collect(); - code_hashes.sort(); - code_hashes.dedup(); - code_hashes + stateless_core::collect_code_hashes(&witness.kvs) } // TracerKind - Unified enum for TracingInspector-based tracers diff --git a/bin/stateless-validator/Cargo.toml b/bin/stateless-validator/Cargo.toml index 33d0a459..743bba87 100644 --- a/bin/stateless-validator/Cargo.toml +++ b/bin/stateless-validator/Cargo.toml @@ -30,13 +30,18 @@ revm = { workspace = true, features = ["serde"] } stateless-common = { path = "../../crates/stateless-common" } stateless-core = { path = "../../crates/stateless-core" } stateless-db = { path = "../../crates/stateless-db" } +stateless-r2 = { path = "../../crates/stateless-r2" } # misc +bytes.workspace = true +chrono = { workspace = true, features = ["clock"] } clap = { workspace = true, features = ["env"] } eyre.workspace = true metrics.workspace = true metrics-exporter-prometheus.workspace = true redb.workspace = true +# rustls-tls gives the R2 witness client HTTPS without a system TLS backend. +reqwest = { workspace = true, features = ["rustls-tls"] } serde_json.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/bin/stateless-validator/src/app.rs b/bin/stateless-validator/src/app.rs index 8761fd6b..5c14bcb4 100644 --- a/bin/stateless-validator/src/app.rs +++ b/bin/stateless-validator/src/app.rs @@ -5,14 +5,50 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; use alloy_genesis::Genesis; use alloy_primitives::BlockHash; use alloy_rpc_types_eth::BlockId; -use clap::Parser; +use clap::{Parser, ValueEnum}; use eyre::Result; use stateless_common::{BackoffPolicy, RpcClient, RpcClientConfig, logging::LogArgs}; use stateless_core::{ChainStore, ContractStore, chain_spec::ChainSpec, db::BlockMeta}; use stateless_db::ContractCache; -use tracing::info; +use tracing::{info, warn}; + +use crate::{metrics, r2_witness::R2WitnessClient, validator_db::ValidatorDB, workers}; + +/// Where the validator sources witnesses from. +#[derive(ValueEnum, Clone, Debug, PartialEq, Eq, Default)] +#[clap(rename_all = "lowercase")] +pub enum WitnessSource { + /// `mega_getBlockWitness` RPC. + #[default] + Rpc, + /// Straight from the R2 bucket over the S3 API. Requires the `--r2-*` flags. + R2, +} + +/// A CLI/env secret that renders as `[redacted]` in `Debug` output, so it cannot leak when +/// [`CommandLineArgs`] (which derives `Debug`) is logged. +#[derive(Clone)] +pub struct RedactedSecret(String); + +impl std::str::FromStr for RedactedSecret { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +impl std::fmt::Debug for RedactedSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[redacted]") + } +} -use crate::{metrics, validator_db::ValidatorDB, workers}; +impl AsRef for RedactedSecret { + fn as_ref(&self) -> &str { + &self.0 + } +} /// Database filename for the validator. pub const VALIDATOR_DB_FILENAME: &str = "validator.redb"; @@ -68,15 +104,45 @@ pub struct CommandLineArgs { /// One or more MegaETH JSON-RPC API endpoints for fetching witness data (tried in order). /// Accepts repeated flags (`--witness-endpoint a --witness-endpoint b`) or a comma-separated /// list (`--witness-endpoint a,b`, also via the env var). + /// + /// Required when `--witness-source rpc` (the default); ignored when `--witness-source r2`. #[clap( long, env = "STATELESS_VALIDATOR_WITNESS_ENDPOINT", - required = true, value_delimiter = ',', action = clap::ArgAction::Append, )] pub witness_endpoint: Vec, + /// Where to source witnesses from: `rpc` (default) or `r2` (requires the `--r2-*` flags). + #[clap(long, env = "STATELESS_VALIDATOR_WITNESS_SOURCE", value_enum, default_value_t = WitnessSource::Rpc)] + pub witness_source: WitnessSource, + + /// R2 S3 endpoint origin, e.g. `https://.r2.cloudflarestorage.com` (no bucket path). + /// Required when `--witness-source r2`. + #[clap(long, env = "STATELESS_VALIDATOR_R2_ENDPOINT")] + pub r2_endpoint: Option, + + /// R2 bucket holding the witnesses (e.g. `witness-mainnet`). Required when `--witness-source + /// r2`. + #[clap(long, env = "STATELESS_VALIDATOR_R2_BUCKET")] + pub r2_bucket: Option, + + /// R2 access key id (Object Read). Required when `--witness-source r2`. + #[clap(long, env = "STATELESS_VALIDATOR_R2_ACCESS_KEY_ID")] + pub r2_access_key_id: Option, + + /// R2 secret access key. Required when `--witness-source r2`. Prefer the env var over the + /// flag. + #[clap(long, env = "STATELESS_VALIDATOR_R2_SECRET_ACCESS_KEY")] + pub r2_secret_access_key: Option, + + /// Optional inclusive end block: validate up to this height, then stop cleanly. Used to slice + /// a fixed block range across multiple servers. Omit to follow the chain tip indefinitely. + /// Note: the run only completes once the chain reaches `end_block + tip_buffer`. + #[clap(long, env = "STATELESS_VALIDATOR_END_BLOCK")] + pub end_block: Option, + /// Optional trusted block hash to start validation from. #[clap(long, env = "STATELESS_VALIDATOR_START_BLOCK")] pub start_block: Option, @@ -92,7 +158,7 @@ pub struct CommandLineArgs { pub report_validation_endpoint: Option, /// Enable Prometheus metrics endpoint. - /// When enabled, metrics are exposed at http://0.0.0.0:/metrics + /// When enabled, metrics are exposed at `http://0.0.0.0:/metrics`. #[clap(long, env = "STATELESS_VALIDATOR_METRICS_ENABLED")] pub metrics_enabled: bool, @@ -105,8 +171,8 @@ pub struct CommandLineArgs { #[clap(long, env = "STATELESS_VALIDATOR_DATA_MAX_CONCURRENT_REQUESTS")] pub data_max_concurrent_requests: Option, - /// Maximum concurrent in-flight witness fetches, independent of the data cap. - /// Omit for unlimited. + /// Maximum concurrent in-flight witness fetches, independent of the data cap. Omit for + /// unlimited. Applies to both RPC witness calls and, with `--witness-source r2`, R2 GETs. #[clap(long, env = "STATELESS_VALIDATOR_WITNESS_MAX_CONCURRENT_REQUESTS")] pub witness_max_concurrent_requests: Option, @@ -135,7 +201,8 @@ pub struct CommandLineArgs { #[clap(long, env = "STATELESS_VALIDATOR_RPC_MAX_BACKOFF_MS")] pub rpc_max_backoff_ms: Option, - /// Per-attempt RPC timeout (milliseconds). Must be ≥ 100ms. + /// Per-attempt RPC timeout (milliseconds). Must be ≥ 100ms. With `--witness-source r2` this + /// also bounds each R2 witness GET. #[clap( long, env = "STATELESS_VALIDATOR_RPC_PER_ATTEMPT_TIMEOUT_MS", @@ -206,8 +273,47 @@ pub async fn run() -> Result<()> { ..rpc_defaults } .with_metrics(Arc::new(metrics::ValidatorMetrics)); + // In R2 mode the RpcClient's witness providers are never used, but its constructor requires + // a non-empty list — hand it the data endpoints as a placeholder. let data_apis: Vec<&str> = args.rpc_endpoint.iter().map(String::as_str).collect(); - let witness_apis: Vec<&str> = args.witness_endpoint.iter().map(String::as_str).collect(); + let r2_witness = match args.witness_source { + WitnessSource::Rpc => { + if args.witness_endpoint.is_empty() { + return Err(eyre::eyre!( + "--witness-endpoint is required with --witness-source rpc (the default)" + )); + } + None + } + WitnessSource::R2 => { + if !args.witness_endpoint.is_empty() { + warn!( + "--witness-endpoint is ignored with --witness-source r2: witnesses come \ + straight from the R2 bucket, and there is no RPC witness fallback" + ); + } + let endpoint = require_r2(&args.r2_endpoint, "--r2-endpoint")?; + let bucket = require_r2(&args.r2_bucket, "--r2-bucket")?; + let access_key_id = require_r2(&args.r2_access_key_id, "--r2-access-key-id")?; + let secret_access_key = + require_r2(&args.r2_secret_access_key, "--r2-secret-access-key")?; + info!(endpoint, bucket, "Witness source: R2 (direct S3)"); + Some(Arc::new(R2WitnessClient::new( + endpoint, + bucket.to_string(), + access_key_id.to_string(), + secret_access_key.to_string(), + per_attempt_timeout, + args.witness_max_concurrent_requests, + )?)) + } + }; + + let witness_apis: Vec<&str> = if r2_witness.is_some() { + data_apis.clone() + } else { + args.witness_endpoint.iter().map(String::as_str).collect() + }; let client = Arc::new(RpcClient::new_with_config( &data_apis, &witness_apis, @@ -271,9 +377,14 @@ pub async fn run() -> Result<()> { pipeline_config.error_restart_delay = override_ms(args.error_restart_delay_ms, pipeline_config.error_restart_delay); pipeline_config.tip_buffer = args.tip_buffer.unwrap_or(DEFAULT_TIP_BUFFER); + pipeline_config.sync_target = args.end_block; + if let Some(end) = args.end_block { + info!(end_block = end, "Validating up to end block, then stopping"); + } let result = workers::run_with_signals( client, + r2_witness, validator_db, contract_cache, chain_spec, @@ -293,3 +404,37 @@ pub async fn run() -> Result<()> { fn override_ms(ms: Option, default: Duration) -> Duration { ms.map(Duration::from_millis).unwrap_or(default) } + +/// Unwraps a required `--r2-*` argument, erroring with the flag name when it is absent. +fn require_r2<'a, T: AsRef>(value: &'a Option, flag: &str) -> Result<&'a str> { + value + .as_ref() + .map(AsRef::as_ref) + .filter(|v| !v.is_empty()) + .ok_or_else(|| eyre::eyre!("{flag} is required with --witness-source r2")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn require_r2_rejects_absent_and_empty_values() { + assert!(require_r2(&None::, "--r2-endpoint").is_err()); + // An env var set to the empty string must not pass as configured. + assert!(require_r2(&Some(String::new()), "--r2-endpoint").is_err()); + assert_eq!( + require_r2(&Some("https://x".to_string()), "--r2-endpoint").unwrap(), + "https://x" + ); + } + + /// `CommandLineArgs` derives `Debug`; the secret must never appear in that output. + #[test] + fn redacted_secret_never_debug_prints_its_value() { + let secret: RedactedSecret = "super-secret-key".parse().unwrap(); + assert_eq!(format!("{secret:?}"), "[redacted]"); + assert_eq!(format!("{:?}", Some(&secret)), "Some([redacted])"); + assert_eq!(secret.as_ref(), "super-secret-key"); + } +} diff --git a/bin/stateless-validator/src/chain_sync.rs b/bin/stateless-validator/src/chain_sync.rs index 9f758801..82cf2529 100644 --- a/bin/stateless-validator/src/chain_sync.rs +++ b/bin/stateless-validator/src/chain_sync.rs @@ -25,12 +25,17 @@ use stateless_db::ContractCache; use tokio::task; use tracing::{debug, error}; -use crate::metrics; +use crate::{metrics, r2_witness::R2WitnessClient}; -/// Fetcher for the validator: fetches blocks + witnesses from RPC, -/// wraps in [`ValidationTask`], and records remote chain height for metrics. +/// Fetcher for the validator: fetches blocks + witnesses, wraps in [`ValidationTask`], and records +/// remote chain height for metrics. +/// +/// Blocks, headers, and contract code always come from the data RPC; the witness comes from +/// `mega_getBlockWitness` (default) or straight from R2 ([`R2WitnessClient`]). pub struct ValidatorFetcher { pub rpc_client: Arc, + /// `Some` ⇒ fetch witnesses directly from R2; `None` ⇒ RPC. + pub r2_witness: Option>, pub on_remote_height: fn(u64), } @@ -41,10 +46,18 @@ impl BlockFetcher for ValidatorFetcher { let block_hash = self.rpc_client.get_block_hash(block_number).await; // Fetch by hash (not number) so a reorg between the hash lookup and the block fetch // surfaces as a hash mismatch rather than silently swapping the block under us. - let ((salt_witness, mpt_witness), block) = tokio::join!( - self.rpc_client.get_witness(block_number, block_hash), - self.rpc_client.get_block(BlockId::Hash(block_hash.into()), true), - ); + let block_fut = self.rpc_client.get_block(BlockId::Hash(block_hash.into()), true); + // The RPC witness path retries internally until it succeeds; an R2 fetch is fallible — + // a 404 (`Missing`) or decode failure surfaces as a fetch error and the pipeline + // re-enqueues. + let witness_fut = async { + match &self.r2_witness { + Some(r2) => Ok::<_, eyre::Report>(r2.get_witness(block_number, block_hash).await?), + None => Ok(self.rpc_client.get_witness(block_number, block_hash).await), + } + }; + let (witness, block) = tokio::join!(witness_fut, block_fut); + let (salt_witness, mpt_witness) = witness?; Ok(ValidationTask { block, salt_witness, mpt_witness }) } diff --git a/bin/stateless-validator/src/lib.rs b/bin/stateless-validator/src/lib.rs index 55367645..a5a8ea2b 100644 --- a/bin/stateless-validator/src/lib.rs +++ b/bin/stateless-validator/src/lib.rs @@ -6,9 +6,14 @@ pub(crate) mod app; pub(crate) mod chain_sync; pub(crate) mod metrics; +pub(crate) mod r2_witness; pub(crate) mod validator_db; pub(crate) mod workers; -pub use app::{CommandLineArgs, VALIDATOR_DB_FILENAME, load_or_create_chain_spec, run}; -pub use chain_sync::{ValidatorFetcher, ValidatorHooks, ValidatorProcessor}; +pub use app::{ + CommandLineArgs, VALIDATOR_DB_FILENAME, WitnessSource, load_or_create_chain_spec, run, +}; +pub use chain_sync::{ValidationTask, ValidatorFetcher, ValidatorHooks, ValidatorProcessor}; +pub use r2_witness::{R2WitnessClient, R2WitnessError}; pub use validator_db::ValidatorDB; +pub use workers::run_with_signals; diff --git a/bin/stateless-validator/src/metrics.rs b/bin/stateless-validator/src/metrics.rs index baf31880..40762768 100644 --- a/bin/stateless-validator/src/metrics.rs +++ b/bin/stateless-validator/src/metrics.rs @@ -17,6 +17,8 @@ pub use stateless_common::{ }; use tracing::info; +use crate::r2_witness::R2WitnessError; + /// Metrics callback implementation for RPC client. /// /// This struct implements the `RpcMetrics` trait from stateless-core, @@ -75,6 +77,11 @@ pub mod names { metric!(CODE_FETCH_TIME, "code_fetch_time_seconds"); metric!(WITNESS_FETCH_RPC_TIME, "witness_fetch_rpc_time_seconds"); + // R2 witness source (`--witness-source r2`) + metric!(WITNESS_FETCH_R2_TIME, "witness_fetch_r2_time_seconds"); + metric!(R2_WITNESS_RETRY_ATTEMPTS_TOTAL, "r2_witness_retry_attempts_total"); + metric!(R2_WITNESS_ERRORS_TOTAL, "r2_witness_errors_total"); + // Contract cache metric!(CONTRACT_CACHE_HITS, "contract_cache_hits_total"); metric!(CONTRACT_CACHE_MISSES, "contract_cache_misses_total"); @@ -118,6 +125,7 @@ pub fn init_metrics(addr: SocketAddr) -> Result<()> { register_metric_descriptions(); init_rpc_method_counters(); + init_r2_witness_counters(); info!("Prometheus exporter listening on {}", addr); Ok(()) } @@ -159,6 +167,20 @@ fn register_metric_descriptions() { describe_histogram!(names::CODE_FETCH_TIME, "Code fetch time (s)"); describe_histogram!(names::WITNESS_FETCH_RPC_TIME, "Witness RPC fetch time (s)"); + // R2 witness source + describe_histogram!( + names::WITNESS_FETCH_R2_TIME, + "R2 witness fetch+decode time incl. internal retries, excl. concurrency-cap queue wait (s)" + ); + describe_counter!( + names::R2_WITNESS_RETRY_ATTEMPTS_TOTAL, + "R2 witness GET retry attempts (before final outcome)" + ); + describe_counter!( + names::R2_WITNESS_ERRORS_TOTAL, + "R2 witness fetches that surfaced an error to the pipeline, by kind" + ); + // Contract cache describe_counter!(names::CONTRACT_CACHE_HITS, "Contract cache hits"); describe_counter!(names::CONTRACT_CACHE_MISSES, "Contract cache misses"); @@ -190,6 +212,15 @@ fn init_rpc_method_counters() { } } +/// Pre-register the R2 witness-source counters (every error kind) so they appear in Prometheus +/// output from startup, like the RPC method counters above. +fn init_r2_witness_counters() { + counter!(names::R2_WITNESS_RETRY_ATTEMPTS_TOTAL).increment(0); + for kind in R2WitnessError::KINDS { + counter!(names::R2_WITNESS_ERRORS_TOTAL, "kind" => *kind).increment(0); + } +} + /// Record validation timing and block statistics after successful validation. #[allow(clippy::too_many_arguments)] pub fn on_validation_success( @@ -294,3 +325,24 @@ pub fn on_witness_fetch(b: WitnessSizeBreakdown) { histogram!(names::SALT_WITNESS_KVS_SIZE).record(b.salt_kvs_size as f64); histogram!(names::MPT_WITNESS_SIZE).record(b.mpt_size as f64); } + +// R2 witness source metrics (`--witness-source r2`) + +/// Record a successful R2 witness fetch: duration (see [`names::WITNESS_FETCH_R2_TIME`]'s +/// description for what it covers) plus the same size breakdown as [`on_witness_fetch`], so the +/// witness-size histograms stay populated in R2 mode. +pub fn on_r2_witness_fetch_success(duration: f64, breakdown: WitnessSizeBreakdown) { + histogram!(names::WITNESS_FETCH_R2_TIME).record(duration); + on_witness_fetch(breakdown); +} + +/// Record one retried R2 witness GET attempt (transport/429/5xx, before the final outcome). +pub fn on_r2_witness_retry() { + counter!(names::R2_WITNESS_RETRY_ATTEMPTS_TOTAL).increment(1); +} + +/// Record an R2 witness fetch that surfaced an error to the pipeline, labelled by +/// [`R2WitnessError::kind`]. +pub fn on_r2_witness_error(kind: &'static str) { + counter!(names::R2_WITNESS_ERRORS_TOTAL, "kind" => kind).increment(1); +} diff --git a/bin/stateless-validator/src/r2_witness.rs b/bin/stateless-validator/src/r2_witness.rs new file mode 100644 index 00000000..af6057dc --- /dev/null +++ b/bin/stateless-validator/src/r2_witness.rs @@ -0,0 +1,530 @@ +//! Direct-from-R2 witness source. +//! +//! Fetches the primary witness object straight from the R2 bucket over the S3 API (a SigV4-signed +//! `GET`), decompresses it, and returns the same `(SaltWitness, MptWitness)` tuple the RPC path +//! yields. +//! +//! The object-key layout, SigV4 signer, and endpoint parsing come from `stateless-r2` — the same +//! crate the witness uploaders write with — so the read path here cannot drift from the write +//! path. The primary object body is `zstd(bincode-legacy((SaltWitness, MptWitness)))` (the +//! uploader's `encode_witness_payload`), which [`stateless_common::decode_witness_payload`] +//! inverts exactly. + +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +use alloy_primitives::B256; +use bytes::Bytes; +use chrono::Utc; +use reqwest::Client; +use salt::SaltWitness; +use stateless_common::{WitnessDecodingError, WitnessSizeBreakdown, decode_witness_payload}; +use stateless_core::withdrawals::MptWitness; +use stateless_r2::{ + client::is_throttle_status, + endpoint::parse_endpoint, + keys, + sigv4::{SigV4Signer, encode_uri_path}, +}; +use tokio::{sync::Semaphore, task::JoinError}; +use tracing::{trace, warn}; + +use crate::metrics; + +/// Max retry rounds for retryable (transport/429/5xx) failures before surfacing an error. +const MAX_RETRIES: usize = 8; +/// First retry sleep; doubles each round up to [`MAX_BACKOFF`]. Test builds shrink all three +/// durations so the retry-path tests run in milliseconds. +const INITIAL_BACKOFF: Duration = + if cfg!(test) { Duration::from_millis(5) } else { Duration::from_millis(500) }; +/// Upper bound on any single retry sleep. +const MAX_BACKOFF: Duration = + if cfg!(test) { Duration::from_millis(20) } else { Duration::from_secs(30) }; +/// Throttle applied before surfacing any deterministic (non-retryable) failure: the pipeline +/// fetcher (`stateless-core/src/pipeline/fetcher.rs`) re-enqueues failed fetches with no delay, +/// so returning instantly would hot-loop signed GETs against R2. Delete this once the fetcher +/// grows per-block re-enqueue backoff. +const DETERMINISTIC_FAILURE_THROTTLE: Duration = + if cfg!(test) { Duration::from_millis(5) } else { Duration::from_secs(2) }; +/// Cap on the response body carried inside `Throttled`/`Status` errors. +const MAX_ERROR_BODY_BYTES: usize = 1024; + +/// Failure outcome of an R2 witness fetch. +#[derive(Debug, thiserror::Error)] +pub enum R2WitnessError { + /// The primary object is absent from the bucket (HTTP 404): a completeness gap in R2, or a + /// transient miss near the tip / right after a reorg, before the uploader has PUT the object. + #[error("R2 witness MISSING for block {number} (key {key}): object not found (404)")] + Missing { number: u64, key: String }, + /// Transport-level failure (connection reset/timeout) — the endpoint is effectively + /// unreachable. Retried internally with backoff before surfacing. + #[error("R2 transport failure for block {number} (key {key}): {source}")] + Transport { number: u64, key: String, source: reqwest::Error }, + /// R2 asked us to slow down (429) or returned a server-side error (5xx, including R2's 503 + /// overload / SlowDown). Retried internally with backoff before surfacing. + #[error("R2 throttled/server error {status} for block {number} (key {key}): {body}")] + Throttled { number: u64, key: String, status: u16, body: String }, + /// A non-success status unlikely to clear on retry: a 4xx other than 429 (e.g. 403 bad + /// credentials, 404 NoSuchBucket) or a 3xx (redirects are never followed — see + /// [`R2WitnessClient::new`]). + #[error("R2 unexpected status {status} for block {number} (key {key}): {body}")] + Status { number: u64, key: String, status: u16, body: String }, + /// The object was fetched but its bytes did not decode to a `(SaltWitness, MptWitness)` tuple + /// — a corrupt witness in R2. Deterministic; not retried. + #[error("R2 witness for block {number} (key {key}) failed to decode: {source}")] + Decode { number: u64, key: String, source: WitnessDecodingError }, + /// The decode task panicked. This is a bug in our own decoder, not a problem with the data in + /// R2, so it is kept out of [`Self::Decode`]. + #[error("R2 witness decode task for block {number} (key {key}) panicked: {source}")] + DecodePanicked { number: u64, key: String, source: JoinError }, +} + +impl R2WitnessError { + /// Every label [`Self::kind`] can produce, for metrics pre-registration + /// (`crate::metrics::init_metrics` zero-inits the error counter per kind). + pub const KINDS: &'static [&'static str] = + &["missing", "transport", "throttled", "status", "decode", "decode_panicked"]; + + /// Stable lowercase label for this variant — the `kind` label on the R2 witness error + /// counter. Every value returned here must appear in [`Self::KINDS`]. + pub const fn kind(&self) -> &'static str { + match self { + Self::Missing { .. } => "missing", + Self::Transport { .. } => "transport", + Self::Throttled { .. } => "throttled", + Self::Status { .. } => "status", + Self::Decode { .. } => "decode", + Self::DecodePanicked { .. } => "decode_panicked", + } + } + + /// Whether an immediate retry against the same endpoint could plausibly succeed (transport + /// blips, 429, 5xx). Every other variant is deterministic and is surfaced without retrying. + const fn is_retryable(&self) -> bool { + matches!(self, Self::Transport { .. } | Self::Throttled { .. }) + } +} + +/// Fetches witness objects straight from an R2 bucket over the S3 API with SigV4-signed GETs. +/// +/// Cloning is cheap — the `reqwest::Client` and signer are internally reference-counted / small. +/// `Debug` is safe to derive: [`SigV4Signer`]'s own `Debug` redacts the credentials. +#[derive(Clone, Debug)] +pub struct R2WitnessClient { + http: Client, + signer: SigV4Signer, + /// Endpoint origin (`scheme://host`, no trailing slash). + endpoint: String, + /// SigV4 canonical host (`host[:port]`). + host: String, + bucket: String, + /// Caps concurrent GETs, honoring `--witness-max-concurrent-requests` (the RPC witness path + /// enforces it inside `RpcClient`, which R2 mode bypasses). + concurrency: Arc, +} + +impl R2WitnessClient { + /// Builds a client from an R2 endpoint origin, bucket, and bucket-scoped S3 credentials. + /// + /// `per_attempt_timeout` bounds each individual GET. `max_concurrent_requests` caps the + /// number of GETs in flight at once (`None` = unlimited, `Some(0)` clamps to 1 — same + /// semantics as the RPC witness semaphore). Fails if the endpoint is not a bare + /// `scheme://host[:port]` origin (see [`parse_endpoint`]) or the HTTP client cannot be built. + pub fn new( + endpoint: &str, + bucket: String, + access_key_id: String, + secret_access_key: String, + per_attempt_timeout: Duration, + max_concurrent_requests: Option, + ) -> eyre::Result { + let (origin, host) = parse_endpoint(endpoint); + if host.is_empty() { + return Err(eyre::eyre!( + "Invalid R2 endpoint {endpoint:?}: expected a bare scheme://host origin \ + (no path/query), e.g. https://.r2.cloudflarestorage.com" + )); + } + let http = Client::builder() + .timeout(per_attempt_timeout) + // A SigV4-signed GET can never survive a redirect (reqwest strips `authorization` on + // cross-host hops, and a same-host hop invalidates the signed URI), so following one + // just turns the real cause into a baffling 403. Surface the 3xx as a `Status` error. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| eyre::eyre!("Failed to build R2 HTTP client: {e}"))?; + Ok(Self { + http, + signer: SigV4Signer::new(access_key_id, secret_access_key), + endpoint: origin, + host, + bucket, + concurrency: Arc::new(Semaphore::new( + max_concurrent_requests.unwrap_or(Semaphore::MAX_PERMITS).max(1), + )), + }) + } + + /// Fetches and decodes the witness for `(number, hash)` from R2. + /// + /// Transport/429/5xx failures are retried with backoff up to `MAX_RETRIES` times. Every + /// other failure is deterministic and surfaces after a short + /// `DETERMINISTIC_FAILURE_THROTTLE` sleep (see its docs for why). + pub async fn get_witness( + &self, + number: u64, + hash: B256, + ) -> Result<(SaltWitness, MptWitness), R2WitnessError> { + let result = self.get_witness_inner(number, hash).await; + if let Err(e) = &result { + metrics::on_r2_witness_error(e.kind()); + if !e.is_retryable() { + tokio::time::sleep(DETERMINISTIC_FAILURE_THROTTLE).await; + } + } + result + } + + /// [`Self::get_witness`] without the deterministic-failure throttle. + async fn get_witness_inner( + &self, + number: u64, + hash: B256, + ) -> Result<(SaltWitness, MptWitness), R2WitnessError> { + let started = Instant::now(); + // Subtracted from the fetch-duration metric below: queue wait on the concurrency cap is + // self-imposed, and folded in it would masquerade as R2 slowness. + let mut queue_wait = Duration::ZERO; + let key = keys::block_object_key(number, hash); + let mut backoff = INITIAL_BACKOFF; + let mut attempt = 0usize; + + let bytes = loop { + attempt += 1; + // Permit scoped to the GET itself — holding it across the backoff sleep or the + // decode below would waste capacity other fetches could use. + let outcome = { + let queued = Instant::now(); + let _permit = self.concurrency.acquire().await.expect("semaphore is never closed"); + queue_wait += queued.elapsed(); + self.get_object(number, &key).await + }; + match outcome { + Ok(bytes) => break bytes, + Err(e) => { + if !e.is_retryable() || attempt > MAX_RETRIES { + return Err(e); + } + metrics::on_r2_witness_retry(); + warn!(number, %key, attempt, error = %e, "R2 witness GET failed, backing off"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + } + } + }; + + // zstd + bincode over a multi-MB witness is CPU-bound; keep it off the runtime. + match tokio::task::spawn_blocking(move || decode_witness_payload(&bytes)).await { + Ok(Ok(witness)) => { + trace!(number, "R2 witness fetched and decoded"); + metrics::on_r2_witness_fetch_success( + started.elapsed().saturating_sub(queue_wait).as_secs_f64(), + WitnessSizeBreakdown::new(&witness.0, &witness.1), + ); + Ok(witness) + } + Ok(Err(source)) => Err(R2WitnessError::Decode { number, key, source }), + Err(source) => Err(R2WitnessError::DecodePanicked { number, key, source }), + } + } + + /// Performs one SigV4-signed GET and classifies the response. No retry. The body comes back + /// as [`Bytes`] (refcounted), so the multi-MB witness is never copied between the HTTP + /// response and the decoder. + async fn get_object(&self, number: u64, key: &str) -> Result { + let canonical_uri = encode_uri_path(&self.bucket, key); + let url = format!("{}{}", self.endpoint, canonical_uri); + // Signed-payload mode with an empty body: x-amz-content-sha256 = sha256(""). + let signed = self.signer.sign("GET", &self.host, &canonical_uri, "", &[], b"", Utc::now()); + + let mut request = self.http.get(&url); + for (name, value) in signed { + request = request.header(name, value); + } + let transport = |source| R2WitnessError::Transport { number, key: key.to_string(), source }; + let response = request.send().await.map_err(transport)?; + + let status = response.status(); + if status.is_success() { + return response.bytes().await.map_err(transport); + } + let code = status.as_u16(); + let mut body = response.text().await.unwrap_or_default(); + // Cap the body carried in the error (and re-printed by the retry `warn!`): real R2 error + // bodies are a few hundred bytes of XML, but a misconfigured endpoint fronted by a + // verbose proxy can return arbitrarily large HTML. + if body.len() > MAX_ERROR_BODY_BYTES { + let mut end = MAX_ERROR_BODY_BYTES; + while !body.is_char_boundary(end) { + end -= 1; + } + body.truncate(end); + } + // A 404 usually means the object is absent (`NoSuchKey`) — but S3 also 404s a missing + // *bucket*, which is operator misconfiguration, not a data gap; keep those apart. + if code == 404 && !body.contains("NoSuchBucket") { + return Err(R2WitnessError::Missing { number, key: key.to_string() }); + } + let key = key.to_string(); + if is_throttle_status(code) { + Err(R2WitnessError::Throttled { number, key, status: code, body }) + } else { + Err(R2WitnessError::Status { number, key, status: code, body }) + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + str::FromStr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + use super::*; + + /// Guards the one layer `stateless-r2` cannot pin itself: that [`B256`]'s `Display` renders + /// full lowercase `0x` hex. If that changed, every GET would 404. + #[test] + fn block_object_key_renders_b256_as_lowercase_hex() { + let hash = + B256::from_str("0x05dd41e545b25db0ce04f628e6e1705232240c70a0435c8233ac4479176fe6b0") + .unwrap(); + assert_eq!( + keys::block_object_key(6_632_136, hash), + "block/6632000_6632999/6632136.\ + 0x05dd41e545b25db0ce04f628e6e1705232240c70a0435c8233ac4479176fe6b0", + ); + } + + #[test] + fn rejects_endpoint_with_path() { + // A bucket-in-path URL is the classic misconfiguration; construction must fail fast. + let err = R2WitnessClient::new( + "https://acc.r2.cloudflarestorage.com/witness-mainnet", + "witness-mainnet".to_string(), + "ak".to_string(), + "sk".to_string(), + Duration::from_secs(20), + None, + ) + .unwrap_err(); + assert!(err.to_string().contains("Invalid R2 endpoint")); + } + + /// Serves one scripted HTTP/1.1 response per connection on a local port and counts requests. + /// The last response repeats if more connections arrive than were scripted. Bodies are + /// anything `Into>` so failure tests pass `&str` and the happy-path test raw bytes. + async fn mock_r2(responses: Vec<(u16, impl Into>)>) -> (String, Arc) { + let responses: Vec<(u16, Vec)> = + responses.into_iter().map(|(status, body)| (status, body.into())).collect(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let hits = Arc::new(AtomicUsize::new(0)); + let counter = hits.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let n = counter.fetch_add(1, Ordering::SeqCst); + let (status, body) = &responses[n.min(responses.len() - 1)]; + // Drain the request head before replying. + let mut buf = [0u8; 4096]; + let _ = sock.read(&mut buf).await; + // The reason phrase is never interpreted; `location` matters only to the + // redirects-not-followed test. + let head = format!( + "HTTP/1.1 {status} X\r\nconnection: close\r\n\ + location: http://example.invalid/elsewhere\r\n\ + content-length: {}\r\n\r\n", + body.len(), + ); + let _ = sock.write_all(head.as_bytes()).await; + let _ = sock.write_all(body).await; + } + }); + (endpoint, hits) + } + + fn client(endpoint: &str) -> R2WitnessClient { + client_with_limit(endpoint, None) + } + + fn client_with_limit(endpoint: &str, limit: Option) -> R2WitnessClient { + R2WitnessClient::new( + endpoint, + "witness-test".to_string(), + "ak".to_string(), + "sk".to_string(), + Duration::from_secs(5), + limit, + ) + .unwrap() + } + + async fn fetch(endpoint: &str) -> Result<(SaltWitness, MptWitness), R2WitnessError> { + client(endpoint).get_witness(1, B256::ZERO).await + } + + #[tokio::test] + async fn status_4xx_surfaces_without_retry() { + let (endpoint, hits) = mock_r2(vec![(403, "SignatureDoesNotMatch")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Status { status: 403, .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), 1, "4xx must not be retried"); + } + + #[tokio::test] + async fn missing_404_surfaces_without_retry() { + let (endpoint, hits) = mock_r2(vec![(404, "NoSuchKey")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Missing { number: 1, .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), 1, "404 must not be retried"); + } + + #[tokio::test] + async fn missing_bucket_404_is_a_config_error_not_a_gap() { + let (endpoint, _) = mock_r2(vec![(404, "NoSuchBucket")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Status { status: 404, .. }), "{err}"); + } + + #[tokio::test] + async fn throttled_5xx_retries_until_a_deterministic_answer() { + let (endpoint, hits) = mock_r2(vec![(503, "SlowDown"), (503, "SlowDown"), (403, "")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Status { status: 403, .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), 3, "5xx must be retried, 4xx must stop the loop"); + } + + #[tokio::test] + async fn persistent_5xx_exhausts_retries_and_surfaces_throttled() { + let (endpoint, hits) = mock_r2(vec![(503, "overloaded")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Throttled { status: 503, .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), MAX_RETRIES + 1, "initial attempt + MAX_RETRIES"); + } + + /// The only test of the success path (`get_object` → `spawn_blocking` decode): a fixture + /// witness encoded with the uploader's `encode_witness_payload` must round-trip to the + /// original tuple. + #[tokio::test] + async fn valid_object_decodes_end_to_end() { + use stateless_test_utils::fixtures::TestFixtures; + + let fixtures = TestFixtures::mainnet_shared(); + let (_, hash) = + fixtures.paired_blocks().into_iter().next().expect("mainnet fixtures have a witness"); + let salt_witness = fixtures.salt_witnesses[&hash].clone(); + let mpt_witness: MptWitness = fixtures.mpt_witness(&hash); + let (_, payload) = stateless_common::encode_witness_payload(&salt_witness, &mpt_witness) + .expect("fixture witness must encode"); + + let (endpoint, hits) = mock_r2(vec![(200, payload)]).await; + let (decoded_salt, decoded_mpt) = + fetch(&endpoint).await.expect("valid object must fetch and decode"); + assert_eq!(decoded_salt, salt_witness); + assert_eq!(decoded_mpt, mpt_witness); + assert_eq!(hits.load(Ordering::SeqCst), 1, "a successful fetch must take exactly one GET"); + } + + #[tokio::test] + async fn undecodable_body_surfaces_decode_without_retry() { + let (endpoint, hits) = mock_r2(vec![(200, "not a zstd witness")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Decode { .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), 1, "a corrupt object must not be re-downloaded"); + } + + #[tokio::test] + async fn redirects_are_not_followed() { + let (endpoint, hits) = mock_r2(vec![(301, "moved")]).await; + let err = fetch(&endpoint).await.unwrap_err(); + assert!(matches!(err, R2WitnessError::Status { status: 301, .. }), "{err}"); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + /// In R2 mode this client is the only enforcement of `--witness-max-concurrent-requests`: + /// six concurrent fetches against a limit of 2 must never exceed two in-flight GETs. + #[tokio::test] + async fn concurrency_limit_bounds_in_flight_gets() { + const LIMIT: usize = 2; + const FETCHES: u64 = 6; + + // Per-connection tasks (unlike `mock_r2`, which serves serially) track the in-flight + // high-water mark; each response is held 50ms so fetches pile up behind the semaphore, + // then answered 404 (deterministic → exactly one GET per fetch). + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let in_flight = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + { + let (in_flight, peak) = (in_flight.clone(), peak.clone()); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let (in_flight, peak) = (in_flight.clone(), peak.clone()); + tokio::spawn(async move { + let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + let mut buf = [0u8; 4096]; + let _ = sock.read(&mut buf).await; + tokio::time::sleep(Duration::from_millis(50)).await; + let response = + "HTTP/1.1 404 X\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"; + let _ = sock.write_all(response.as_bytes()).await; + in_flight.fetch_sub(1, Ordering::SeqCst); + }); + } + }); + } + + let client = client_with_limit(&endpoint, Some(LIMIT)); + let mut fetches = tokio::task::JoinSet::new(); + for number in 0..FETCHES { + let client = client.clone(); + fetches.spawn(async move { client.get_witness(number, B256::ZERO).await }); + } + while let Some(result) = fetches.join_next().await { + let err = result.unwrap().unwrap_err(); + assert!(matches!(err, R2WitnessError::Missing { .. }), "{err}"); + } + + let peak = peak.load(Ordering::SeqCst); + assert!(peak <= LIMIT, "peak in-flight GETs {peak} exceeded the limit {LIMIT}"); + // Liveness guard: with six fetches, a 2-permit semaphore, and 50ms-held responses, + // the limit must actually be reached — otherwise this test can't have observed it. + assert_eq!(peak, LIMIT, "expected the fetches to saturate the concurrency limit"); + } + + /// Every deterministic failure must be throttled before surfacing (see + /// [`DETERMINISTIC_FAILURE_THROTTLE`] for why). + #[tokio::test] + async fn deterministic_failures_are_throttled_before_surfacing() { + for (status, body) in [(403, ""), (404, ""), (200, "garbage")] { + let (endpoint, _) = mock_r2(vec![(status, body)]).await; + let started = std::time::Instant::now(); + fetch(&endpoint).await.unwrap_err(); + assert!( + started.elapsed() >= DETERMINISTIC_FAILURE_THROTTLE, + "status {status} surfaced without the deterministic-failure throttle", + ); + } + } +} diff --git a/bin/stateless-validator/src/workers.rs b/bin/stateless-validator/src/workers.rs index 356d6876..77450ab5 100644 --- a/bin/stateless-validator/src/workers.rs +++ b/bin/stateless-validator/src/workers.rs @@ -16,15 +16,22 @@ use tracing::{debug, error, info, warn}; use crate::{ chain_sync::{ValidatorFetcher, ValidatorHooks, ValidatorProcessor}, metrics, + r2_witness::R2WitnessClient, validator_db::ValidatorDB, }; +/// Attempts for the final shutdown report (first try + retries). +const FINAL_REPORT_ATTEMPTS: usize = 3; +/// Sleep between final-report attempts. +const FINAL_REPORT_RETRY_DELAY: Duration = Duration::from_secs(1); + /// Starts the validator pipeline, optional reporter, and signal handlers. /// /// Cleanly drains on SIGINT/SIGTERM and returns either the pipeline result or `Ok(())` /// on signal. pub async fn run_with_signals( client: Arc, + r2_witness: Option>, validator_db: Arc, contract_cache: Arc, chain_spec: Arc, @@ -47,6 +54,7 @@ pub async fn run_with_signals( let fetcher = Arc::new(ValidatorFetcher { rpc_client: client.clone(), + r2_witness, on_remote_height: metrics::set_remote_chain_height, }); let processor = @@ -55,7 +63,7 @@ pub async fn run_with_signals( let reporter = if report_validation { Some(task::spawn(validation_reporter( - client, + Arc::clone(&client), Arc::clone(&validator_db), Duration::from_secs(1), shutdown.clone(), @@ -109,6 +117,36 @@ pub async fn run_with_signals( let _ = tokio::time::timeout(Duration::from_secs(3), reporter).await; } + // Final report of the validated tail, sent after the pipeline (and any drain) has stopped + // and the periodic reporter was joined. The reporter exits the moment the pipeline does, so + // blocks validated since its last tick would otherwise go unreported — and an `--end-block` + // slice run has no later restart to re-report them, hence the bounded retries (the periodic + // loop's next tick is its retry). Re-reporting an already-reported tip is harmless (every + // fresh start does it), and a reporter wedged past the 3s join above cannot regress + // upstream: reports apply through a forward-only cursor. + if report_validation { + let mut last_reported = 0u64; + for attempt in 1..=FINAL_REPORT_ATTEMPTS { + match report_range_once(&client, &validator_db, &mut last_reported).await { + Ok(true) => break, + Ok(false) if attempt < FINAL_REPORT_ATTEMPTS => { + warn!(attempt, "Final validation report failed, retrying"); + tokio::time::sleep(FINAL_REPORT_RETRY_DELAY).await; + } + Ok(false) => error!( + attempts = FINAL_REPORT_ATTEMPTS, + "Final validation report failed; the validated tail may be unreported \ + upstream" + ), + // A detected validation gap is deterministic — retrying cannot resolve it. + Err(e) => { + warn!(error = %e, "Final validation report failed"); + break; + } + } + } + } + // Canonical chain advances strictly +1 (advancer enforces parent-hash continuity and // rolls back on reorg), so the final tip bounds the validated range exactly. match (initial_tip, validator_db.get_canonical_tip()?.map(|t| t.block_number)) { @@ -129,7 +167,8 @@ pub async fn run_with_signals( /// Reports validated blocks to the dedicated report endpoint. /// /// Periodically reads the canonical tip from ValidatorDB and reports the -/// validated range to the upstream node. +/// validated range to the upstream node. Exits as soon as `shutdown` fires; +/// `run_with_signals` flushes the final tail afterwards. async fn validation_reporter( client: Arc, validator_db: Arc, @@ -148,53 +187,71 @@ async fn validation_reporter( } } - let (anchor, tip) = match (validator_db.get_anchor(), validator_db.get_canonical_tip()) { - (Ok(Some(a)), Ok(Some(t))) => (a, t), - (Ok(None), _) | (_, Ok(None)) => continue, - (Err(e), _) | (_, Err(e)) => { - warn!(error = %e, "Failed to read anchor/tip, retrying"); - continue; - } - }; + report_range_once(&client, &validator_db, &mut last_reported_block).await?; + } +} - if tip.block_number == last_reported_block { - continue; +/// One reporter round: read anchor + tip and report the validated range upstream if the tip +/// differs from `last_reported_block` (updated on an accepted report; a tip that regressed +/// after a reorg rollback is deliberately re-reported). +/// +/// Returns `Ok(true)` when the round settled (report accepted, or nothing to report) and +/// `Ok(false)` when the attempt failed in a way a retry could resolve (logged here). The only +/// `Err` is a detected validation gap, which is fatal to the reporter. +async fn report_range_once( + client: &RpcClient, + validator_db: &ValidatorDB, + last_reported_block: &mut u64, +) -> Result { + let (anchor, tip) = match (validator_db.get_anchor(), validator_db.get_canonical_tip()) { + (Ok(Some(a)), Ok(Some(t))) => (a, t), + (Ok(None), _) | (_, Ok(None)) => return Ok(true), + (Err(e), _) | (_, Err(e)) => { + warn!(error = %e, "Failed to read anchor/tip, retrying"); + return Ok(false); } + }; - let result = client - .set_validated_blocks( - (anchor.block_number, B256::from(anchor.block_hash.0)), - (tip.block_number, B256::from(tip.block_hash.0)), - ) - .await; - - match result { - Ok(response) if response.accepted => { - debug!( - anchor = anchor.block_number, - anchor_hash = %anchor.block_hash, - tip = tip.block_number, - tip_hash = %tip.block_hash, - "Reported blocks" - ); - last_reported_block = tip.block_number; - } - Ok(response) => { - if response.last_validated_block.0 < anchor.block_number { - return Err(eyre::eyre!( - "Validation gap detected: upstream at block {}, but local chain starts at {}", - response.last_validated_block.0, - anchor.block_number - )); - } - error!( - upstream_block = ?response.last_validated_block, - "Report rejected" - ); - } - Err(e) => { - error!(error = %e, "Failed to report blocks"); + if tip.block_number == *last_reported_block { + return Ok(true); + } + + let result = client + .set_validated_blocks( + (anchor.block_number, B256::from(anchor.block_hash.0)), + (tip.block_number, B256::from(tip.block_hash.0)), + ) + .await; + + match result { + Ok(response) if response.accepted => { + debug!( + anchor = anchor.block_number, + anchor_hash = %anchor.block_hash, + tip = tip.block_number, + tip_hash = %tip.block_hash, + "Reported blocks" + ); + *last_reported_block = tip.block_number; + Ok(true) + } + Ok(response) => { + if response.last_validated_block.0 < anchor.block_number { + return Err(eyre::eyre!( + "Validation gap detected: upstream at block {}, but local chain starts at {}", + response.last_validated_block.0, + anchor.block_number + )); } + error!( + upstream_block = ?response.last_validated_block, + "Report rejected" + ); + Ok(false) + } + Err(e) => { + error!(error = %e, "Failed to report blocks"); + Ok(false) } } } diff --git a/bin/stateless-validator/tests/integration.rs b/bin/stateless-validator/tests/integration.rs index 84b541b5..21d64dd3 100644 --- a/bin/stateless-validator/tests/integration.rs +++ b/bin/stateless-validator/tests/integration.rs @@ -3,7 +3,10 @@ //! Covers CLI argument parsing and end-to-end pipeline validation against a mock RPC server. //! Mainnet single-block validation is covered in `crates/stateless-core/src/executor.rs::tests`. -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; use alloy_primitives::{B256, BlockHash}; use alloy_rpc_types_eth::Block; @@ -15,7 +18,7 @@ use jsonrpsee::{ use jsonrpsee_types::error::{ CALL_EXECUTION_FAILED_CODE, ErrorObject, ErrorObjectOwned, INVALID_PARAMS_CODE, }; -use stateless_common::{RpcClient, WitnessRequestKeys, encode_witness_response}; +use stateless_common::{RpcClient, RpcClientConfig, WitnessRequestKeys, encode_witness_response}; use stateless_core::{ BisectResolver, ChainStore, ContractStore, PipelineConfig, db::BlockMeta, pipeline::run_pipeline, withdrawals::MptWitness, @@ -24,7 +27,7 @@ use stateless_db::ContractCache; use stateless_test_utils::{fixtures::TestFixtures, logging::init_test_logging}; use stateless_validator::{ CommandLineArgs, VALIDATOR_DB_FILENAME, ValidatorDB, ValidatorFetcher, ValidatorHooks, - ValidatorProcessor, load_or_create_chain_spec, + ValidatorProcessor, load_or_create_chain_spec, run_with_signals, }; use tokio_util::sync::CancellationToken; use tracing::{debug, info}; @@ -41,6 +44,11 @@ const BASE_ARGS: &[&str] = &[ "http://w", ]; +/// [`BASE_ARGS`] without `--witness-endpoint`, for tests that exercise that flag itself or its +/// absence. +const BASE_ARGS_NO_WITNESS: &[&str] = + &["stateless-validator", "--data-dir", "/tmp/x", "--rpc-endpoint", "http://rpc"]; + /// Verifies that an endpoint flag accepts repeated flags, CSV values, and env var — /// ensuring container deployments configured purely via env are not silently limited /// to one endpoint (clap's `value_delimiter` applies to env-var values too). @@ -71,7 +79,7 @@ fn witness_endpoint_accepts_multiple_forms() { assert_endpoint_accepts_multiple_forms( "--witness-endpoint", "STATELESS_VALIDATOR_WITNESS_ENDPOINT", - &["stateless-validator", "--data-dir", "/tmp/x", "--rpc-endpoint", "http://rpc"], + BASE_ARGS_NO_WITNESS, |a| a.witness_endpoint, ); } @@ -133,6 +141,47 @@ fn tip_buffer_flag_and_env() { }); } +#[test] +fn end_block_flag_and_env() { + assert_optional_numeric_flag::("--end-block", "STATELESS_VALIDATOR_END_BLOCK", |a| { + a.end_block + }); +} + +/// `--witness-source` must default to `rpc`, parse both lowercase values (flag and env), and +/// reject anything else at parse time. +#[test] +fn witness_source_flag_and_env() { + use stateless_validator::WitnessSource; + + let guard = stateless_test_utils::env::env_lock(); + let parse = |extra: &[&str]| CommandLineArgs::try_parse_from(BASE_ARGS.iter().chain(extra)); + + assert_eq!(parse(&[]).unwrap().witness_source, WitnessSource::Rpc); + assert_eq!(parse(&["--witness-source", "rpc"]).unwrap().witness_source, WitnessSource::Rpc); + assert_eq!(parse(&["--witness-source", "r2"]).unwrap().witness_source, WitnessSource::R2); + assert!(parse(&["--witness-source", "s3"]).is_err()); + + let from_env = stateless_test_utils::env::with_env_var( + &guard, + "STATELESS_VALIDATOR_WITNESS_SOURCE", + "r2", + || parse(&[]).unwrap().witness_source, + ); + assert_eq!(from_env, WitnessSource::R2); +} + +/// `--witness-endpoint` is enforced at runtime per witness source (required for `rpc`, ignored +/// for `r2`), so the parse itself must accept its absence in both modes. +#[test] +fn witness_endpoint_is_optional_at_parse_time() { + let parse = + |extra: &[&str]| CommandLineArgs::try_parse_from(BASE_ARGS_NO_WITNESS.iter().chain(extra)); + + assert!(parse(&[]).unwrap().witness_endpoint.is_empty()); + assert!(parse(&["--witness-source", "r2"]).unwrap().witness_endpoint.is_empty()); +} + /// `canonical_chain_max_length` must reject 0 at parse time. A value of 0 would make /// `advance_chain` prune the entire canonical chain on every successful advance, /// rolling the pipeline back to the anchor each round and looping forever. @@ -164,6 +213,10 @@ const MAX_RESPONSE_BODY_SIZE: u32 = 1024 * 1024 * 100; struct MockServerState { fixtures: TestFixtures, mpt_witnesses: HashMap, + /// Every *accepted* `mega_setValidatedBlocks` call, as `(first_block, last_block)` numbers. + validated_reports: Arc>>, + /// Number of upcoming `mega_setValidatedBlocks` calls to reject with an RPC error. + reject_reports: Arc, } impl MockServerState { @@ -173,7 +226,12 @@ impl MockServerState { .keys() .map(|hash| (*hash, fixtures.mpt_witness(hash))) .collect(); - Self { fixtures, mpt_witnesses } + Self { + fixtures, + mpt_witnesses, + validated_reports: Arc::default(), + reject_reports: Arc::default(), + } } } @@ -347,9 +405,20 @@ async fn setup_mock_rpc_server( .unwrap(); module - .register_method("mega_setValidatedBlocks", |params, _ctx, _| { - let (_first_block, last_block): ((u64, String), (u64, String)) = - params.parse().unwrap(); + .register_method("mega_setValidatedBlocks", |params, ctx, _| { + use std::sync::atomic::Ordering; + let (first_block, last_block): ((u64, String), (u64, String)) = params.parse().unwrap(); + if ctx + .reject_reports + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok() + { + return Err(make_rpc_error( + CALL_EXECUTION_FAILED_CODE, + "transient report failure (scripted)".to_string(), + )); + } + ctx.validated_reports.lock().unwrap().push((first_block.0, last_block.0)); let last_hash: BlockHash = last_block.1.parse().unwrap(); Ok::(serde_json::json!({ "accepted": true, @@ -392,8 +461,11 @@ async fn integration_test() { let config = Arc::new(cfg); let shutdown = CancellationToken::new(); - let fetcher = - Arc::new(ValidatorFetcher { rpc_client: client.clone(), on_remote_height: |_| {} }); + let fetcher = Arc::new(ValidatorFetcher { + rpc_client: client.clone(), + r2_witness: None, + on_remote_height: |_| {}, + }); let processor = Arc::new(ValidatorProcessor { chain_spec, contract_cache, rpc_client: client }); let hooks = Arc::new(ValidatorHooks); @@ -420,3 +492,78 @@ async fn integration_test() { handle.stop().unwrap(); info!("Mock RPC server has been shut down"); } + +/// Runs `run_with_signals` to the fixtures' max block (`--end-block` → `sync_target`) with +/// reports wired to the mock, failing the first `reject_first_reports` calls; asserts the last +/// accepted report covers the end block and returns all accepted reports. +async fn run_end_block_slice_and_assert_tip_reported( + reject_first_reports: usize, +) -> Vec<(u64, u64)> { + let _logging = init_test_logging("stateless_validator"); + let fx = TestFixtures::synthetic(); + let genesis_file = fx.data_dir.join("genesis.json"); + + let max_block_number = fx.max_block().0; + let (validator_db, _tmp) = setup_test_db(&fx).unwrap(); + let contract_cache = + Arc::new(ContractCache::new(Arc::clone(&validator_db) as Arc)); + let state = MockServerState::new(fx); + let reports = Arc::clone(&state.validated_reports); + state.reject_reports.store(reject_first_reports, std::sync::atomic::Ordering::SeqCst); + let (handle, url) = setup_mock_rpc_server(state).await; + let client = Arc::new( + RpcClient::new_with_config( + &[url.as_str()], + &[url.as_str()], + RpcClientConfig::validator(), + Some(url.as_str()), + ) + .unwrap(), + ); + let chain_spec = Arc::new( + load_or_create_chain_spec(&validator_db, Some(genesis_file.to_str().unwrap())).unwrap(), + ); + + let mut cfg = PipelineConfig::default(); + cfg.concurrent_workers = 1; + cfg.sync_target = Some(max_block_number); + + run_with_signals( + client, + None, + Arc::clone(&validator_db), + contract_cache, + chain_spec, + Some(url.clone()), + cfg, + ) + .await + .unwrap(); + + handle.stop().unwrap(); + + let reports = reports.lock().unwrap(); + let &(_, last_reported) = + reports.last().expect("the run must report validated blocks before exiting"); + assert_eq!( + last_reported, max_block_number, + "the final report must cover the end block (got reports: {reports:?})", + ); + reports.clone() +} + +/// A fixed-range run must flush its final validated tip before exiting: the periodic reporter +/// is cancelled when the pipeline completes (its 1s tick rarely fires on a short slice) and a +/// slice run has no restart to re-report, so the final flush in `run_with_signals` is the only +/// path. +#[tokio::test] +async fn end_block_run_reports_final_tip() { + run_end_block_slice_and_assert_tip_reported(0).await; +} + +/// Like [`end_block_run_reports_final_tip`], but the mock rejects the first report call: the +/// final flush's bounded retry must still land the tip. +#[tokio::test] +async fn end_block_final_report_retries_after_transient_failure() { + run_end_block_slice_and_assert_tip_reported(1).await; +} diff --git a/crates/stateless-common/Cargo.toml b/crates/stateless-common/Cargo.toml index 6f84cf3e..143e2664 100644 --- a/crates/stateless-common/Cargo.toml +++ b/crates/stateless-common/Cargo.toml @@ -36,7 +36,7 @@ eyre.workspace = true fastrand = { workspace = true, features = ["std"] } futures.workspace = true # Enables gzip/brotli on alloy-provider's reqwest 0.12 (Cargo feature unification) for witness/data fetches; not referenced in code. -reqwest = { version = "0.12", default-features = false, features = ["gzip", "brotli"] } +reqwest = { workspace = true, features = ["gzip", "brotli"] } rolling-file.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/crates/stateless-common/src/lib.rs b/crates/stateless-common/src/lib.rs index 582a7f60..b598f95b 100644 --- a/crates/stateless-common/src/lib.rs +++ b/crates/stateless-common/src/lib.rs @@ -9,7 +9,8 @@ pub use rpc_client::{ pub mod witness_encoding; pub use witness_encoding::{ WITNESS_RESPONSE_VERSION_PREFIX, WITNESS_ZSTD_LEVEL, WitnessDecodingError, - WitnessEncodingError, decode_witness_payload, decode_witness_response, encode_witness_payload, + WitnessEncodingError, decode_witness_payload, decode_witness_payload_light, + decode_witness_response, decode_witness_response_light, encode_witness_payload, encode_witness_response, }; pub mod witness_size; diff --git a/crates/stateless-common/src/rpc_client.rs b/crates/stateless-common/src/rpc_client.rs index 07c4575c..27e11f4d 100644 --- a/crates/stateless-common/src/rpc_client.rs +++ b/crates/stateless-common/src/rpc_client.rs @@ -45,13 +45,13 @@ use op_alloy_rpc_types::Transaction; use revm::state::Bytecode; use salt::SaltWitness; use serde::{Deserialize, Serialize}; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, withdrawals::MptWitness}; use tokio::sync::Semaphore; use tracing::{trace, warn}; use crate::{ metrics::{RpcMethod, RpcMetrics}, - witness_encoding::decode_witness_response, + witness_encoding::{decode_witness_response, decode_witness_response_light}, witness_size::WitnessSizeBreakdown, }; @@ -590,6 +590,44 @@ impl RpcClient { Ok(witness) } + /// Zero-validation counterpart of [`Self::get_witness`] for execution-only + /// consumers: decodes just the light witness (kvs + levels, no + /// elliptic-curve work — see `stateless_core::light_witness` for the + /// safety model). Consumers that later need the full witness (e.g. to + /// assemble test fixtures) re-fetch it via [`Self::get_witness`]. + /// + /// The `on_witness_fetch` size metric is not recorded here — the exact + /// breakdown needs the proof's commitment count. Callers that want a size + /// signal can record `WitnessSizeBreakdown::new_light` (a documented + /// lower bound) themselves. + pub async fn get_witness_light(&self, number: u64, hash: B256) -> (LightWitness, MptWitness) { + self.get_witness_light_with_deadline(number, hash, None) + .await + .expect("None deadline cannot time out") + } + + /// Deadline-aware counterpart of [`Self::get_witness_light`]. + pub async fn get_witness_light_with_deadline( + &self, + number: u64, + hash: B256, + deadline: Option, + ) -> std::result::Result<(LightWitness, MptWitness), RpcDeadlineExceeded> { + round_robin_with_backoff( + &self.witness_providers, + &self.witness_concurrency, + &self.config.rpc_retry, + self.config.per_attempt_timeout, + // Primary-failover, same as `get_witness`. + 0, + RpcMethod::MegaGetBlockWitness, + self.config.metrics.as_ref(), + deadline, + |provider| Box::pin(async move { fetch_witness_light(&provider, number, hash).await }), + ) + .await + } + /// Reports a range of validated blocks via the dedicated report endpoint. pub async fn set_validated_blocks( &self, @@ -1059,6 +1097,37 @@ async fn fetch_witness_raw( number: u64, hash: B256, ) -> Result<(SaltWitness, MptWitness)> { + fetch_witness_with(provider, number, hash, decode_witness_response, "Witness decoded").await +} + +/// Zero-validation counterpart of [`fetch_witness_raw`]: decodes only the +/// light witness with +/// [`decode_witness_response_light`](crate::decode_witness_response_light). +async fn fetch_witness_light( + provider: &RootProvider, + number: u64, + hash: B256, +) -> Result<(LightWitness, MptWitness)> { + fetch_witness_with( + provider, + number, + hash, + decode_witness_response_light, + "Witness light-decoded", + ) + .await +} + +/// Shared single-attempt `mega_getBlockWitness` fetch: one RPC round trip, +/// then the caller-chosen decoder on the blocking pool (zstd + bincode over a +/// multi-MB payload is CPU-bound). +async fn fetch_witness_with( + provider: &RootProvider, + number: u64, + hash: B256, + decode: fn(&str) -> std::result::Result, + trace_msg: &'static str, +) -> Result { let keys = WitnessRequestKeys { block_number: U64::from(number), block_hash: hash }; let encoded: String = provider .client() @@ -1067,22 +1136,20 @@ async fn fetch_witness_raw( .map_err(|e| eyre!("mega_getBlockWitness failed for block {number}: {e}"))?; let decode_start = Instant::now(); - let (salt_witness, mpt_witness) = - tokio::task::spawn_blocking(move || -> Result<(SaltWitness, MptWitness)> { - decode_witness_response(&encoded) - .map_err(|e| eyre!("failed to decode witness response: {e}")) - }) - .await - .context("decode task panicked")??; + let result = tokio::task::spawn_blocking(move || -> Result { + decode(&encoded).map_err(|e| eyre!("failed to decode witness response: {e}")) + }) + .await + .context("decode task panicked")??; trace!( block_number = number, %hash, decode_ms = decode_start.elapsed().as_millis(), - "Witness decoded", + trace_msg, ); - Ok((salt_witness, mpt_witness)) + Ok(result) } /// Verifies structural integrity of a block fetched from RPC. diff --git a/crates/stateless-common/src/witness_encoding.rs b/crates/stateless-common/src/witness_encoding.rs index 64142cb9..37b81acc 100644 --- a/crates/stateless-common/src/witness_encoding.rs +++ b/crates/stateless-common/src/witness_encoding.rs @@ -9,7 +9,7 @@ use std::io; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use salt::SaltWitness; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, LightWitnessFromSalt, withdrawals::MptWitness}; /// Version prefix for the RPC response format: /// `"v0:" + base64(zstd(bincode-legacy((SaltWitness, MptWitness))))`. @@ -73,6 +73,19 @@ pub fn decode_witness_payload( Ok(witness) } +/// Zero-validation counterpart of [`decode_witness_payload`]: decodes only the +/// light witness (kvs + levels) from the same payload bytes, skipping all +/// elliptic-curve work (see `stateless_core::light_witness` for the safety +/// model). ~80x less CPU than the full decode on large witnesses. +pub fn decode_witness_payload_light( + compressed: &[u8], +) -> Result<(LightWitness, MptWitness), WitnessDecodingError> { + let decompressed = zstd::decode_all(compressed)?; + let ((light, mpt), _): ((LightWitnessFromSalt, MptWitness), usize) = + bincode::serde::decode_from_slice(&decompressed, bincode::config::legacy())?; + Ok((light.0, mpt)) +} + /// Encodes the witness tuple as a versioned RPC response string. pub fn encode_witness_response( salt_witness: &SaltWitness, @@ -93,6 +106,18 @@ pub fn decode_witness_response( decode_witness_payload(&compressed) } +/// Zero-validation counterpart of [`decode_witness_response`]: decodes only +/// the light witness from a versioned RPC response. +pub fn decode_witness_response_light( + response: &str, +) -> Result<(LightWitness, MptWitness), WitnessDecodingError> { + let payload = response + .strip_prefix(WITNESS_RESPONSE_VERSION_PREFIX) + .ok_or(WitnessDecodingError::MissingPrefix)?; + let compressed = BASE64.decode(payload)?; + decode_witness_payload_light(&compressed) +} + #[cfg(test)] mod tests { use stateless_test_utils::fixtures::TestFixtures; @@ -138,6 +163,54 @@ mod tests { assert_eq!(decoded.1, mpt_witness); } + /// Same payload bytes, light decode: equal to the light parts of the full + /// decode, without touching any curve point. + #[test] + fn decode_witness_payload_light_matches_full() { + let (salt_witness, mpt_witness) = first_fixture_witness(); + let (_, compressed) = encode_witness_payload(&salt_witness, &mpt_witness) + .expect("compression should succeed"); + + let (light, mpt) = + decode_witness_payload_light(&compressed).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&salt_witness)); + assert_eq!(mpt, mpt_witness); + } + + /// Response-level light decode agrees with the full decode. + #[test] + fn decode_witness_response_light_matches_full() { + let (salt_witness, mpt_witness) = first_fixture_witness(); + let encoded = + encode_witness_response(&salt_witness, &mpt_witness).expect("encoding should succeed"); + + let (light, mpt) = + decode_witness_response_light(&encoded).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&salt_witness)); + assert_eq!(mpt, mpt_witness); + } + + /// The committed real-mainnet payload (block 6906405, ~6.3 MiB + /// uncompressed, 65k commitments) light-decodes to exactly the light + /// parts of its full decode — the end-to-end ".zst → light witness" lock. + #[test] + fn big_mainnet_zst_light_decodes() { + let path = + concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_data/mainnet/bench/6906405.zst"); + let compressed = std::fs::read(path).expect("read committed bench payload"); + + let (full_salt, full_mpt) = + decode_witness_payload(&compressed).expect("full decode should succeed"); + let (light, mpt) = + decode_witness_payload_light(&compressed).expect("light decode should succeed"); + + assert_eq!(light, LightWitness::from(&full_salt)); + assert_eq!(mpt, full_mpt); + assert!(!light.kvs.is_empty()); + } + #[test] fn decode_witness_response_requires_prefix() { let err = decode_witness_response("not-versioned").expect_err("missing prefix should fail"); diff --git a/crates/stateless-common/src/witness_size.rs b/crates/stateless-common/src/witness_size.rs index 4d09b116..d9cfb490 100644 --- a/crates/stateless-common/src/witness_size.rs +++ b/crates/stateless-common/src/witness_size.rs @@ -5,7 +5,7 @@ //! (`on_witness_fetch`) and the trace server's data provider. use salt::SaltWitness; -use stateless_core::withdrawals::MptWitness; +use stateless_core::{LightWitness, withdrawals::MptWitness}; /// Per-entry size of a SALT key-value pair: `SaltKey` (8 bytes) plus /// `Option` (~95 bytes). @@ -52,6 +52,21 @@ impl WitnessSizeBreakdown { Self { salt_size, kvs_count, salt_kvs_size, mpt_size } } + /// Computes the breakdown for a light-decoded witness. + /// + /// A [`LightWitness`] never materializes the parent commitments, so their + /// contribution is unknowable here and `salt_size` is a lower bound + /// (KVs + levels + the fixed IPA overhead). Use only for observability on + /// light-decode paths; full-decode paths should keep [`Self::new`]. + pub fn new_light(light: &LightWitness, mpt: &MptWitness) -> Self { + let kvs_count = light.kvs.len(); + let salt_kvs_size = kvs_count * SALT_KV_BYTES; + let proof_size = SALT_IPA_PROOF_BYTES + light.levels.len() * SALT_LEVEL_BYTES; + let salt_size = salt_kvs_size + proof_size; + let mpt_size = MPT_STORAGE_ROOT_BYTES + mpt.state.iter().map(|b| b.len()).sum::(); + Self { salt_size, kvs_count, salt_kvs_size, mpt_size } + } + /// Sum of `salt_size + mpt_size`. pub fn total(&self) -> usize { self.salt_size + self.mpt_size @@ -62,3 +77,36 @@ impl WitnessSizeBreakdown { pub fn estimate_witness_size(salt: &SaltWitness, mpt: &MptWitness) -> usize { WitnessSizeBreakdown::new(salt, mpt).total() } + +#[cfg(test)] +mod tests { + use stateless_test_utils::fixtures::TestFixtures; + + use super::*; + + /// `new_light` must agree with the full breakdown on everything except + /// the parent-commitments term it cannot know: same kv count and MPT + /// size, and a salt_size that is exactly the full figure minus the + /// commitments contribution. + #[test] + fn light_breakdown_is_the_documented_lower_bound() { + let fixtures = TestFixtures::mainnet_shared(); + let (_, hash) = fixtures.paired_blocks().into_iter().next().expect("paired fixture"); + let salt = &fixtures.salt_witnesses[&hash]; + let mpt: MptWitness = fixtures.mpt_witness(&hash); + let light = LightWitness::from(salt); + + let full = WitnessSizeBreakdown::new(salt, &mpt); + let lower = WitnessSizeBreakdown::new_light(&light, &mpt); + + assert_eq!(lower.kvs_count, full.kvs_count); + assert_eq!(lower.salt_kvs_size, full.salt_kvs_size); + assert_eq!(lower.mpt_size, full.mpt_size); + assert_eq!( + full.salt_size - lower.salt_size, + salt.proof.parents_commitments.len() * SALT_COMMITMENT_BYTES, + "the gap must be exactly the commitments term" + ); + assert!(lower.total() <= full.total()); + } +} diff --git a/crates/stateless-core/src/data_types.rs b/crates/stateless-core/src/data_types.rs index 6476086e..85433832 100644 --- a/crates/stateless-core/src/data_types.rs +++ b/crates/stateless-core/src/data_types.rs @@ -208,6 +208,15 @@ pub fn iter_code_hashes( }) } +/// [`iter_code_hashes`], deduplicated and sorted for stable ordering — the +/// form every witness fetcher wants (trace server, coverage replayer). +pub fn collect_code_hashes(kvs: &BTreeMap>) -> Vec { + let mut hashes: Vec = iter_code_hashes(kvs).collect(); + hashes.sort_unstable(); + hashes.dedup(); + hashes +} + #[cfg(test)] mod tests { use std::vec; diff --git a/crates/stateless-core/src/lib.rs b/crates/stateless-core/src/lib.rs index b407a4c1..37deadd7 100644 --- a/crates/stateless-core/src/lib.rs +++ b/crates/stateless-core/src/lib.rs @@ -23,7 +23,7 @@ extern crate alloc as std; pub mod chain_spec; pub mod light_witness; -pub use light_witness::{LightWitness, LightWitnessExecutor}; +pub use light_witness::{LightWitness, LightWitnessExecutor, LightWitnessFromSalt}; pub mod evm_database; pub use evm_database::{WitnessDatabase, WitnessDatabaseError, WitnessExternalEnv}; pub mod db; @@ -31,7 +31,7 @@ pub use db::{ BlockMeta, ChainStore, ContractStore, MissingDataKind, StoreError, StoreResult, StoreResultExt, }; pub mod data_types; -pub use data_types::{PlainKey, PlainValue, iter_code_hashes}; +pub use data_types::{PlainKey, PlainValue, collect_code_hashes, iter_code_hashes}; pub mod executor; pub use executor::{BlockInput, ValidationError, ValidationStats, replay_block, validate_block}; #[cfg(feature = "std")] diff --git a/crates/stateless-core/src/light_witness.rs b/crates/stateless-core/src/light_witness.rs index 95c7e371..780988a7 100644 --- a/crates/stateless-core/src/light_witness.rs +++ b/crates/stateless-core/src/light_witness.rs @@ -1,34 +1,49 @@ //! Light witness deserialization for tracing/execution. //! -//! This module provides a fast witness type that skips expensive cryptographic -//! point validation during deserialization. The standard `SaltWitness` type -//! deserializes `SerdeCommitment` which calls `Element::from_bytes()` for -//! elliptic curve point validation - this is slow (~240ms for large witnesses). +//! Execution-only consumers (debug-trace-server, replay/coverage tooling) read +//! state from a witness but never verify its cryptographic proof. This module +//! provides [`LightWitness`] — just the witnessed key-values and bucket +//! subtree levels — plus two ways to obtain it cheaply: //! -//! For debug-trace-server, we only need the state data (`kvs`) and bucket levels -//! (`proof.levels`) for execution. We don't need the cryptographic proofs since -//! we trust our own database. +//! - [`LightWitness::from`] an already-decoded `SaltWitness` (copies only the light parts), and +//! - [`LightWitnessFromSalt`], a serde adapter that decodes the light parts **directly from full +//! `SaltWitness` bytes**: the proof material is parsed structurally (so the stream stays in sync) +//! but read as raw bytes and discarded — no curve point is ever constructed or validated. //! -//! ## Performance +//! ## Performance (real mainnet witness, ~6.3 MiB, 65k commitments, 14 cores) //! -//! - Standard `SaltWitness` deserialization: ~240ms (due to EC point validation) -//! - `LightWitness` deserialization: ~10-20ms (skips EC point validation) +//! - Full `SaltWitness` decode: ~110 ms wall even with salt's parallelized point validation (salt +//! #137) — and still ~1 core·s of CPU, since one `Element::from_bytes` (modular sqrt + subgroup +//! check) runs per parent commitment. +//! - [`LightWitnessFromSalt`] decode from the same bytes: ~1.4 ms, single-threaded. +//! +//! ## Safety model +//! +//! The zero-validation path performs no cryptographic checks: corrupt or +//! malicious proof bytes decode successfully. Only use it where witness +//! integrity is guaranteed elsewhere (trusted local storage, or a stream a +//! validator has already verified). Never use it on the proof-verification +//! path. -use core::ops::RangeInclusive; +use core::{fmt, ops::RangeInclusive}; use std::{collections::BTreeMap, vec::Vec}; use hashbrown::HashMap; use rustc_hash::FxBuildHasher; -use salt::{BucketId, BucketMeta, SaltKey, SaltValue, bucket_metadata_key, traits::StateReader}; -use serde::{Deserialize, Serialize}; +use salt::{ + BucketId, BucketMeta, NodeId, SaltKey, SaltValue, bucket_metadata_key, traits::StateReader, +}; +use serde::{Deserialize, Deserializer, Serialize}; type FxHashMap = HashMap; /// Light witness that only contains data needed for execution. /// -/// This struct mirrors `SaltWitness` but stores proof data as raw bytes -/// instead of deserializing the expensive `SerdeCommitment` types. -#[derive(Clone, Debug, Serialize, Deserialize)] +/// The derived `Serialize`/`Deserialize` round-trip this two-field struct in +/// its own compact layout (used for local storage, e.g. the trace server DB +/// and the coverage-replayer spool). To decode from full `SaltWitness` bytes +/// instead, use [`LightWitnessFromSalt`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct LightWitness { /// All witnessed key-value pairs (same as SaltWitness.kvs) pub kvs: BTreeMap>, @@ -52,6 +67,91 @@ impl From<&salt::SaltWitness> for LightWitness { } } +/// Newtype adapter whose `Deserialize` impl consumes a full `SaltWitness` +/// stream and keeps only the light parts, skipping all elliptic-curve work +/// (see the module docs for the safety model). +/// +/// Use it positionally wherever full witness bytes are decoded, e.g. +/// `bincode::serde::decode_from_slice::<(LightWitnessFromSalt, MptWitness), _>(..)` +/// against bytes produced from `(SaltWitness, MptWitness)`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LightWitnessFromSalt(pub LightWitness); + +impl<'de> Deserialize<'de> for LightWitnessFromSalt { + fn deserialize>(deserializer: D) -> Result { + from_salt_witness::deserialize(deserializer).map(Self) + } +} + +/// Decoding of a [`LightWitness`] from a full-`SaltWitness` serde stream — +/// the implementation behind [`LightWitnessFromSalt`] (the only public +/// surface; make this module public if a `#[serde(deserialize_with = ...)]` +/// consumer ever appears). +/// +/// The mirror types below must stay field-for-field congruent with +/// `salt::SaltWitness` / `salt::SaltProof` (same field names, order, and wire +/// shapes); the fixture tests in this module lock that in against real +/// mainnet witnesses. +mod from_salt_witness { + use serde::de::{MapAccess, Visitor}; + + use super::*; + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + let mirror = WitnessMirror::deserialize(d)?; + Ok(LightWitness { kvs: mirror.kvs, levels: mirror.proof.levels }) + } + + /// Serde-layout mirror of `salt::SaltWitness`. + #[derive(Deserialize)] + struct WitnessMirror { + kvs: BTreeMap>, + proof: ProofMirror, + } + + /// Serde-layout mirror of `salt::SaltProof`. Proof material is consumed as + /// raw bytes and dropped; only `levels` is materialized. + #[derive(Deserialize)] + struct ProofMirror { + #[serde(deserialize_with = "discard_parents_commitments")] + #[allow(dead_code)] + parents_commitments: (), + #[serde(deserialize_with = "discard_ipa_proof_bytes")] + #[allow(dead_code)] + proof: (), + #[serde(with = "salt::fx_hashmap_serde")] + levels: FxHashMap, + } + + /// Consumes the `NodeId -> [u8; 32]` commitments map without building + /// anything: no `BTreeMap`, no `Element::from_bytes`, no subgroup checks. + fn discard_parents_commitments<'de, D: Deserializer<'de>>(d: D) -> Result<(), D::Error> { + struct DiscardMap; + + impl<'de> Visitor<'de> for DiscardMap { + type Value = (); + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a map of NodeId to 32-byte compressed commitments") + } + + fn visit_map>(self, mut access: A) -> Result<(), A::Error> { + while access.next_entry::()?.is_some() {} + Ok(()) + } + } + + d.deserialize_map(DiscardMap) + } + + /// Consumes the IPA proof exactly as it was written (`SerdeMultiPointProof` + /// serializes its `to_bytes()` output as a `Vec`) without calling + /// `MultiPointProof::from_bytes`. + fn discard_ipa_proof_bytes<'de, D: Deserializer<'de>>(d: D) -> Result<(), D::Error> { + Vec::::deserialize(d).map(drop) + } +} + /// Error type for LightWitness StateReader operations #[derive(Debug, Clone, thiserror::Error)] #[error("{message}")] @@ -82,7 +182,14 @@ impl StateReader for LightWitness { match self.kvs.get(&metadata_key) { Some(Some(salt_value)) => BucketMeta::try_from(salt_value.clone()) .map_err(|_| LightWitnessError { message: "Failed to decode metadata" }), - Some(None) => unreachable!("Metadata should never be stored as None in witness"), + // A well-formed witness never maps a metadata key to a deletion, + // but witness bytes are network input (and the light decode + // validates nothing) — this must be an error, not a panic: a + // panic here takes down the whole consumer (RPC handler task, + // coverage worker process) on one corrupt response. + Some(None) => { + Err(LightWitnessError { message: "Corrupt witness: metadata key maps to None" }) + } None => Err(LightWitnessError { message: "Metadata not in witness" }), } } @@ -195,6 +302,10 @@ impl LightWitnessExecutor { #[cfg(test)] mod tests { + // `std` is the `alloc` alias in no_std builds, where the prelude carries + // no `vec!` — import it explicitly (same as chain_spec.rs). + use std::vec; + use super::*; #[test] @@ -204,6 +315,23 @@ mod tests { assert!(fast.levels.is_empty()); } + /// A corrupt witness that maps a bucket's metadata key to `None` must + /// surface as a `StateReader` error, not a panic: witness bytes are + /// unvalidated network input on the light path, and a panic here kills + /// the whole consumer (RPC handler, coverage worker) instead of failing + /// one request. + #[test] + fn metadata_key_mapped_to_none_is_an_error_not_a_panic() { + // First valid data-bucket id (bucket_metadata_key asserts the range). + let bucket: BucketId = 65536; + let mut kvs: BTreeMap> = BTreeMap::new(); + kvs.insert(bucket_metadata_key(bucket), None); + let witness = LightWitness { kvs, levels: FxHashMap::default() }; + + let err = witness.metadata(bucket).expect_err("must not panic"); + assert!(err.message.contains("Corrupt witness"), "got: {err}"); + } + /// Round-trip a populated `LightWitness` through bincode to confirm the /// `#[serde(with = "salt::fx_hashmap_serde")]` wiring on the `levels` /// field actually works end-to-end. The adapter itself is covered by @@ -226,4 +354,81 @@ mod tests { assert_eq!(decoded.levels.get(k), Some(v)); } } + + /// Every real mainnet fixture witness light-decodes from the exact bytes + /// of its full encoding (wire config, bincode legacy), consuming the + /// stream to the last byte. This is the layout-congruence lock for the + /// mirror types in [`from_salt_witness`]. + #[test] + fn light_decodes_from_full_witness_bytes() { + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + assert!(!fixtures.salt_witnesses.is_empty(), "no fixture witnesses"); + + for (hash, witness) in &fixtures.salt_witnesses { + let bytes = bincode::serde::encode_to_vec(witness, bincode::config::legacy()).unwrap(); + let (light, consumed): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()) + .unwrap_or_else(|e| panic!("light decode {hash}: {e}")); + + assert_eq!(consumed, bytes.len(), "{hash} light decode left trailing bytes"); + assert_eq!(light.0, LightWitness::from(witness), "{hash} light parts mismatch"); + assert!(!light.0.kvs.is_empty(), "{hash} decoded no kvs"); + } + } + + /// The layout mirror is bincode-config-agnostic (varint vs fixint). + #[test] + fn light_decode_is_config_agnostic() { + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + let witness = fixtures.salt_witnesses.values().next().unwrap(); + + let bytes = bincode::serde::encode_to_vec(witness, bincode::config::standard()).unwrap(); + let (light, _): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + + assert_eq!(light.0, LightWitness::from(witness)); + } + + /// The point of the light path: proof bytes are NOT validated. A stream + /// whose commitments are not valid curve points fails the full decode but + /// light-decodes fine. + #[test] + fn light_decode_skips_ec_validation() { + #[derive(Serialize)] + struct RawWitness { + kvs: BTreeMap>, + proof: RawProof, + } + #[derive(Serialize)] + struct RawProof { + parents_commitments: BTreeMap, + proof: Vec, + #[serde(with = "salt::fx_hashmap_serde")] + levels: FxHashMap, + } + + let fixtures = stateless_test_utils::fixtures::TestFixtures::mainnet_shared(); + let real = fixtures.salt_witnesses.values().next().unwrap(); + let raw = RawWitness { + kvs: real.kvs.clone(), + proof: RawProof { + // 0xFF..FF is not a valid compressed banderwagon point. + parents_commitments: [(7u64, [0xFF; 32]), (9u64, [0xFF; 32])].into(), + proof: vec![0xAB; 64], + levels: real.proof.levels.clone(), + }, + }; + let bytes = bincode::serde::encode_to_vec(&raw, bincode::config::legacy()).unwrap(); + + // Full decode rejects the garbage point... + let full: Result<(salt::SaltWitness, usize), _> = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()); + assert!(full.is_err(), "full decode must validate curve points"); + + // ...the light decode never looks at it. + let (light, _): (LightWitnessFromSalt, usize) = + bincode::serde::decode_from_slice(&bytes, bincode::config::legacy()).unwrap(); + assert_eq!(light.0.kvs, real.kvs); + assert_eq!(light.0.levels, real.proof.levels); + } } diff --git a/crates/stateless-core/src/pipeline/fetcher.rs b/crates/stateless-core/src/pipeline/fetcher.rs index 11de0385..b66393b7 100644 --- a/crates/stateless-core/src/pipeline/fetcher.rs +++ b/crates/stateless-core/src/pipeline/fetcher.rs @@ -30,6 +30,8 @@ struct FetcherState { /// Blocks awaiting retry. The RPC client retries transient errors internally, so failures /// bubbling up here are rare (integrity-check failures from corrupt providers). Re-enqueue /// without delay — a retry that rotates round-robin to a different provider will succeed. + /// Single-endpoint sources have no rotation, so they must pace their own deterministic + /// failures (e.g. the R2 witness client's throttle) — remove those if backoff lands here. failed: HashSet, } diff --git a/crates/stateless-r2/Cargo.toml b/crates/stateless-r2/Cargo.toml new file mode 100644 index 00000000..2922e809 --- /dev/null +++ b/crates/stateless-r2/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stateless-r2" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true +description = "Shared Cloudflare R2 (S3-compatible) witness primitives: minimal SigV4 signer, object-key layout, endpoint parsing, and signed PUT helper shared by the mega-reth witness uploaders (write path) and the stateless validator (read path)." + +[dependencies] +# misc +bytes.workspace = true +chrono = { workspace = true, features = ["clock"] } +hex = { workspace = true, features = ["alloc"] } +hmac.workspace = true +percent-encoding.workspace = true +# Pinned explicitly (not workspace-inherited): `put_object` exposes `&reqwest::Client`, so the +# reqwest major is part of this crate's API and must match mega-reth's workspace (0.12). Bumping +# it is a coordinated two-repo change that a workspace-wide bump must not ride over. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +sha2.workspace = true diff --git a/crates/stateless-r2/src/client.rs b/crates/stateless-r2/src/client.rs new file mode 100644 index 00000000..f731ce1d --- /dev/null +++ b/crates/stateless-r2/src/client.rs @@ -0,0 +1,137 @@ +//! Signed `PUT` helper and response classification for R2 uploads. +//! +//! [`put_object`] signs a single object `PUT` with `SigV4` (signed-payload mode) and sends it, +//! mapping the outcome to [`R2Error`]. Both uploaders share this so the set of statuses that should +//! trigger a backoff stays identical between the two binaries. + +use bytes::Bytes; +use chrono::Utc; +use reqwest::Client; + +use crate::sigv4::{Header, SigV4Signer, encode_uri_path}; + +/// Failure outcome of a signed R2 `PUT`. +#[derive(Debug)] +pub enum R2Error { + /// The endpoint host was empty (endpoint not configured or not a valid URL); nothing was sent. + InvalidEndpoint, + /// The request never produced an HTTP response — a transport-level failure such as a + /// connection timeout or reset. The endpoint is effectively unreachable, so the caller + /// should back off before retrying. + Transport(String), + /// The endpoint asked us to slow down (`429`) or returned a server-side error (`5xx`, + /// including R2's `503` overload / `SlowDown`). Retrying without a backoff would keep + /// hammering a struggling endpoint, so the caller should back off. + Throttled { + /// HTTP status code returned by R2. + status: u16, + /// Response body (best-effort), included for diagnostics. + body: String, + }, + /// A non-success status that is unlikely to clear on an immediate retry (typically a `4xx` + /// other than `429`). The caller may requeue but a pool-wide backoff is not warranted. + Status { + /// HTTP status code returned by R2. + status: u16, + /// Response body (best-effort), included for diagnostics. + body: String, + }, +} + +impl R2Error { + /// Whether this failure means the endpoint itself is unhealthy/overloaded and the caller + /// should apply a backoff (transport failures, `429`, and any `5xx`) rather than retry + /// immediately. + pub const fn is_backoff_worthy(&self) -> bool { + matches!(self, Self::Transport(_) | Self::Throttled { .. }) + } +} + +impl std::fmt::Display for R2Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidEndpoint => write!(f, "R2 endpoint is not a valid URL"), + Self::Transport(err) => write!(f, "R2 request transport failure: {err}"), + Self::Throttled { status, body } => { + write!(f, "R2 throttled or server error: {status} - {body}") + } + Self::Status { status, body } => write!(f, "R2 request failed: {status} - {body}"), + } + } +} + +impl std::error::Error for R2Error {} + +/// Uploads a single object to R2 with a SigV4-signed `PUT` request. +/// +/// `endpoint` is the origin (`scheme://host`, no trailing slash), `host` the `SigV4` canonical host +/// (`host[:port]`), `bucket`/`key` the destination object, `body` the object bytes, and `meta` the +/// `x-amz-meta-*` headers to store alongside the object (empty for pointer objects). +/// +/// `body` is a [`Bytes`], so the caller can hand off a cheap clone of an already-buffered payload; +/// `reqwest` consumes it without an extra copy. +// The connection parameters (client/signer/endpoint/host/bucket) live on the caller's uploader +// struct; passing them through keeps this helper stateless and avoids a second copy of that state. +#[allow(clippy::too_many_arguments)] +pub async fn put_object( + client: &Client, + signer: &SigV4Signer, + endpoint: &str, + host: &str, + bucket: &str, + key: &str, + body: Bytes, + meta: &[Header], +) -> Result<(), R2Error> { + if host.is_empty() { + return Err(R2Error::InvalidEndpoint); + } + let canonical_uri = encode_uri_path(bucket, key); + let url = format!("{endpoint}{canonical_uri}"); + let signed = signer.sign("PUT", host, &canonical_uri, "", meta, &body, Utc::now()); + + let mut request = client.put(&url).body(body); + for (name, value) in signed { + request = request.header(name, value); + } + let response = request.send().await.map_err(|err| R2Error::Transport(err.to_string()))?; + classify_response(response).await +} + +/// Whether a non-success status (`429` or any `5xx`) is backoff-worthy throttling, as opposed to +/// one that will not clear on retry. The single definition of the throttle set — the write path +/// (`classify_response`) and the validator's R2 reader both classify with it. +pub const fn is_throttle_status(status: u16) -> bool { + status == 429 || status >= 500 +} + +/// Classifies an R2 (S3 API) response into [`R2Error`], treating [`is_throttle_status`] statuses +/// as backoff-worthy throttling and every other non-success status as a plain failure. +async fn classify_response(response: reqwest::Response) -> Result<(), R2Error> { + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let code = status.as_u16(); + let body = response.text().await.unwrap_or_default(); + if is_throttle_status(code) { + Err(R2Error::Throttled { status: code, body }) + } else { + Err(R2Error::Status { status: code, body }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_worthy_covers_transport_and_throttled_only() { + assert!(R2Error::Transport("timeout".to_string()).is_backoff_worthy()); + assert!(R2Error::Throttled { status: 429, body: String::new() }.is_backoff_worthy()); + assert!(R2Error::Throttled { status: 503, body: String::new() }.is_backoff_worthy()); + assert!(R2Error::Throttled { status: 500, body: String::new() }.is_backoff_worthy()); + assert!(!R2Error::Status { status: 403, body: String::new() }.is_backoff_worthy()); + assert!(!R2Error::InvalidEndpoint.is_backoff_worthy()); + } +} diff --git a/crates/stateless-r2/src/endpoint.rs b/crates/stateless-r2/src/endpoint.rs new file mode 100644 index 00000000..16dba278 --- /dev/null +++ b/crates/stateless-r2/src/endpoint.rs @@ -0,0 +1,84 @@ +//! R2 endpoint parsing shared by both witness uploaders. + +use reqwest::Url; + +/// Parses an R2 endpoint into its origin (`scheme://host[:port]`, no trailing slash) and the +/// request host (`host[:port]`) used for `SigV4` canonical headers. +/// +/// Returns empty strings when the endpoint is unusable: not a valid URL, no host, or anything +/// beyond a bare origin (path/query/fragment). A path would be sent on the wire but never signed +/// (the signer builds `/{bucket}/{key}` itself), failing every request with 403 +/// `SignatureDoesNotMatch` — so e.g. a pasted R2 dashboard bucket URL is rejected at startup. +pub fn parse_endpoint(endpoint: &str) -> (String, String) { + let empty = || (String::new(), String::new()); + let trimmed = endpoint.trim_end_matches('/'); + let Ok(url) = Url::parse(trimmed) else { return empty() }; + let Some(host_str) = url.host_str() else { return empty() }; + + // Accept only a bare origin. `Url` normalizes a hostname-only URL to a "/" path, so treat "/" + // (and the empty path) as "no path"; any other path, query, or fragment is rejected. + let has_path = !matches!(url.path(), "" | "/"); + if has_path || url.query().is_some() || url.fragment().is_some() { + return empty(); + } + + let host = match url.port() { + Some(port) => format!("{host_str}:{port}"), + None => host_str.to_string(), + }; + // Reconstruct the origin from the parsed components rather than echoing the input, so no path + // can leak into it even if the trimming above ever misses a shape. + let origin = format!("{}://{host}", url.scheme()); + (origin, host) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_endpoint_strips_trailing_slash_and_extracts_host() { + let (endpoint, host) = parse_endpoint("https://acc.r2.cloudflarestorage.com/"); + assert_eq!(endpoint, "https://acc.r2.cloudflarestorage.com"); + assert_eq!(host, "acc.r2.cloudflarestorage.com"); + } + + #[test] + fn parse_endpoint_keeps_non_default_port() { + let (endpoint, host) = parse_endpoint("http://localhost:9000"); + assert_eq!(endpoint, "http://localhost:9000"); + assert_eq!(host, "localhost:9000"); + } + + #[test] + fn parse_endpoint_rejects_invalid_url() { + let (endpoint, host) = parse_endpoint("not a url"); + assert!(endpoint.is_empty()); + assert!(host.is_empty()); + } + + #[test] + fn parse_endpoint_rejects_endpoint_with_path() { + let (endpoint, host) = + parse_endpoint("https://acc.r2.cloudflarestorage.com/witness-testnet"); + assert!(endpoint.is_empty(), "an endpoint with a path must be rejected"); + assert!(host.is_empty(), "an endpoint with a path must be rejected"); + + // A trailing-slash-only path is still just the origin and must be accepted. + let (endpoint, host) = parse_endpoint("https://acc.r2.cloudflarestorage.com/"); + assert_eq!(endpoint, "https://acc.r2.cloudflarestorage.com"); + assert_eq!(host, "acc.r2.cloudflarestorage.com"); + } + + #[test] + fn parse_endpoint_rejects_query_and_fragment() { + assert_eq!( + parse_endpoint("https://acc.r2.cloudflarestorage.com/?x=1"), + (String::new(), String::new()) + ); + assert_eq!( + parse_endpoint("https://acc.r2.cloudflarestorage.com/#frag"), + (String::new(), String::new()) + ); + } +} diff --git a/crates/stateless-r2/src/keys.rs b/crates/stateless-r2/src/keys.rs new file mode 100644 index 00000000..040b46d6 --- /dev/null +++ b/crates/stateless-r2/src/keys.rs @@ -0,0 +1,164 @@ +//! R2 object-key layout shared by the witness writers and the validator's reader. +//! +//! Every witness is archived as three objects under a `{range_start}_{range_end}` bucket: +//! - the **primary** object `block/{range}/{number}.{hash}` carrying the compressed witness bytes; +//! - an **attr** pointer `attr/{range}/{parent_hash}.{attr_hash}`; +//! - a **num** pointer `num/{range}/{number}`. +//! +//! This module is the single home of that layout plus the shared [`pointer_body`] and the +//! `x-amz-meta-*` [`witness_metadata`], so the producers and the reader cannot drift. Retention +//! is a bucket lifecycle rule targeting these prefixes (see the crate-level docs). + +use std::fmt::Display; + +use crate::sigv4::Header; + +/// Object-key prefix for the primary witness object (the compressed witness body). +pub const BLOCK_PREFIX: &str = "block"; + +/// Object-key prefix for the `(parent_hash, attributes_hash)` reference pointer. +pub const ATTR_PREFIX: &str = "attr"; + +/// Object-key prefix for the by-block-number reference pointer. +pub const NUM_PREFIX: &str = "num"; + +/// Block range size for grouping keys: ranges let R2 list and lifecycle operations target +/// contiguous blocks by prefix. +pub const BLOCK_RANGE_SIZE: u64 = 1000; + +/// Calculate the range-bucket prefix for grouping blocks into ranges. +/// +/// For example: +/// - Block 0-999 → prefix 0 +/// - Block 1000-1999 → prefix 1000 +/// - Block 2500 → prefix 2000 +#[inline] +pub const fn block_range_prefix(block_number: u64) -> u64 { + (block_number / BLOCK_RANGE_SIZE) * BLOCK_RANGE_SIZE +} + +/// Builds just the primary witness object key: `block/{range}/{number}.{hash}`. +/// +/// The single implementation of the primary-key template: both the write path ([`object_keys`]) +/// and the validator's reader build the key here, so they can never disagree on where a witness +/// lives. +pub fn block_object_key(block_number: u64, block_hash: impl Display) -> String { + let range_start = block_range_prefix(block_number); + let range_end = range_start + BLOCK_RANGE_SIZE - 1; + format!("{BLOCK_PREFIX}/{range_start}_{range_end}/{block_number}.{block_hash}") +} + +/// Builds the three R2 object keys for a witness from its block number and identifying hashes. +/// +/// Keys use a `{range_start}_{range_end}` bucket where `range_start = (number / 1000) * 1000`, +/// decimal block numbers, and lowercase hex hashes (alloy `B256` formats lowercase via `Display`). +/// +/// Returns `(block_key, attr_key, num_key)`. +pub fn object_keys( + block_number: u64, + block_hash: impl Display, + parent_hash: impl Display, + op_attr_hash: impl Display, +) -> (String, String, String) { + let range_start = block_range_prefix(block_number); + let range_end = range_start + BLOCK_RANGE_SIZE - 1; + let range = format!("{range_start}_{range_end}"); + let block_key = block_object_key(block_number, block_hash); + let attr_key = format!("{ATTR_PREFIX}/{range}/{parent_hash}.{op_attr_hash}"); + let num_key = format!("{NUM_PREFIX}/{range}/{block_number}"); + (block_key, attr_key, num_key) +} + +/// Builds the plaintext body shared by both pointer objects: `"{block_number}.{block_hash}"`. +/// +/// Both pointer objects (`attr/...` and `num/...`) store this same reference to the primary object. +/// The stateless validator parses it, so the generator and the replayer must produce it +/// identically. +pub fn pointer_body(block_number: u64, block_hash: impl Display) -> String { + format!("{block_number}.{block_hash}") +} + +/// Builds the `x-amz-meta-*` custom-metadata headers stored alongside the primary witness object. +/// +/// The generator and the replayer must emit identical header names, order, and values. +pub fn witness_metadata( + original_size: usize, + compressed_size: usize, + parent_hash: impl Display, + op_attr_hash: impl Display, +) -> Vec
{ + vec![ + ("x-amz-meta-compression".to_string(), "zstd".to_string()), + ("x-amz-meta-original-size".to_string(), original_size.to_string()), + ("x-amz-meta-compressed-size".to_string(), compressed_size.to_string()), + ("x-amz-meta-parent-hash".to_string(), parent_hash.to_string()), + ("x-amz-meta-attr-hash".to_string(), op_attr_hash.to_string()), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn block_range_prefix_buckets_by_thousand() { + assert_eq!(block_range_prefix(0), 0); + assert_eq!(block_range_prefix(999), 0); + assert_eq!(block_range_prefix(1000), 1000); + assert_eq!(block_range_prefix(1001), 1000); + assert_eq!(block_range_prefix(2500), 2000); + assert_eq!(block_range_prefix(9999), 9000); + assert_eq!(block_range_prefix(10000), 10000); + } + + #[test] + fn object_keys_use_expected_layout() { + let (block_key, attr_key, num_key) = object_keys(2500, "0xblock", "0xparent", "0xattr"); + assert_eq!(block_key, "block/2000_2999/2500.0xblock"); + assert_eq!(attr_key, "attr/2000_2999/0xparent.0xattr"); + assert_eq!(num_key, "num/2000_2999/2500"); + } + + /// Golden wire vector: the key of a real migrated mainnet object, transcribed from the + /// production R2 bucket — not generated by this code — so the key template is certified + /// against the bytes actually in R2 rather than against itself. + #[test] + fn block_object_key_matches_migrated_mainnet_object() { + assert_eq!( + block_object_key( + 6_632_136, + "0x05dd41e545b25db0ce04f628e6e1705232240c70a0435c8233ac4479176fe6b0", + ), + "block/6632000_6632999/6632136.\ + 0x05dd41e545b25db0ce04f628e6e1705232240c70a0435c8233ac4479176fe6b0", + ); + } + + /// The write path's block key must stay byte-identical to the read path's — both must go + /// through [`block_object_key`]. + #[test] + fn object_keys_block_key_delegates_to_block_object_key() { + let (block_key, _, _) = object_keys(2500, "0xblock", "0xparent", "0xattr"); + assert_eq!(block_key, block_object_key(2500, "0xblock")); + } + + #[test] + fn pointer_body_is_number_dot_hash() { + assert_eq!(pointer_body(2500, "0xblock"), "2500.0xblock"); + } + + #[test] + fn witness_metadata_emits_expected_headers() { + let meta = witness_metadata(4096, 1024, "0xparent", "0xattr"); + assert_eq!( + meta, + vec![ + ("x-amz-meta-compression".to_string(), "zstd".to_string()), + ("x-amz-meta-original-size".to_string(), "4096".to_string()), + ("x-amz-meta-compressed-size".to_string(), "1024".to_string()), + ("x-amz-meta-parent-hash".to_string(), "0xparent".to_string()), + ("x-amz-meta-attr-hash".to_string(), "0xattr".to_string()), + ] + ); + } +} diff --git a/crates/stateless-r2/src/lib.rs b/crates/stateless-r2/src/lib.rs new file mode 100644 index 00000000..fd2213f5 --- /dev/null +++ b/crates/stateless-r2/src/lib.rs @@ -0,0 +1,29 @@ +//! Shared Cloudflare R2 (S3-compatible) witness primitives. +//! +//! Both witness producers — mega-reth's standalone witness generator (`bin/stateless/witness`) and +//! its replayer uploader (`bin/replayer/src/uploader`) — archive block witnesses to the same +//! Cloudflare R2 bucket using R2's S3-compatible API, and this repo's validator reads them back +//! (`bin/stateless-validator/src/r2_witness.rs`). The request signing, object-key layout, and +//! response handling must be byte-for-byte identical across all of them, or the validator can no +//! longer locate or authenticate against the uploaded objects. This crate is the single home for +//! those primitives so the writers and the reader cannot drift: +//! +//! - [`sigv4`] — a minimal AWS Signature Version 4 signer for buffered `PUT`/`GET`/`DELETE` +//! requests; +//! - [`keys`] — the `block/`, `attr/`, `num/` object-key scheme and its block-range bucketing; +//! - [`endpoint`] — parsing an R2 endpoint into the origin and `SigV4` canonical host; +//! - [`client`] — a signed `PUT` helper that classifies the response into a small retry-friendly +//! error set ([`client::R2Error`]). +//! +//! ## Object retention +//! +//! Objects are written with **no per-object expiry**, so retention must be enforced by an R2 +//! **bucket lifecycle rule**. The [`keys`] layout buckets objects under the `block/`, `attr/`, and +//! `num/` prefixes (and `{range_start}_{range_end}` sub-folders) precisely so a lifecycle rule can +//! target contiguous block ranges by prefix. If no lifecycle rule is configured, the bucket grows +//! without bound. + +pub mod client; +pub mod endpoint; +pub mod keys; +pub mod sigv4; diff --git a/crates/stateless-r2/src/sigv4.rs b/crates/stateless-r2/src/sigv4.rs new file mode 100644 index 00000000..2f0a4508 --- /dev/null +++ b/crates/stateless-r2/src/sigv4.rs @@ -0,0 +1,310 @@ +//! Minimal AWS Signature Version 4 signer for S3-compatible object storage. +//! +//! The witness uploaders write to Cloudflare R2 through R2's S3 API. R2 authenticates requests with +//! AWS `SigV4` (`service = "s3"`, `region = "auto"`), so this module implements just the slice of +//! `SigV4` the uploaders need: signing a single `PUT` / `GET` / `DELETE` request whose payload is +//! fully buffered in memory. +//! +//! Only the "signed payload" mode is implemented (`x-amz-content-sha256 = hex(sha256(body))`), +//! which is appropriate because compressed witnesses are at most tens of MiB and are already held +//! as buffered bytes on the upload path. Streaming / `UNSIGNED-PAYLOAD` is intentionally omitted. +//! +//! Reference: . + +use chrono::{DateTime, Utc}; +use hmac::{Hmac, Mac}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +/// `SigV4` signing algorithm identifier. +const ALGORITHM: &str = "AWS4-HMAC-SHA256"; + +/// The `aws4_request` terminator used by both the credential scope and the signing key. +const REQUEST_TYPE: &str = "aws4_request"; + +/// Characters that do **not** need percent-encoding in a `SigV4` canonical URI path segment. +/// +/// AWS leaves the RFC 3986 *unreserved* set (`A-Z a-z 0-9 - _ . ~`) untouched and percent-encodes +/// everything else. `NON_ALPHANUMERIC` encodes every non-alphanumeric byte, so we remove the four +/// unreserved punctuation characters from it. The path separator `/` is handled by the caller, +/// which encodes each segment independently and rejoins them with `/`. +const URI_SEGMENT: &AsciiSet = + &NON_ALPHANUMERIC.remove(b'-').remove(b'_').remove(b'.').remove(b'~'); + +/// A single HTTP header (lowercase name, value) that participates in signing and is sent on the +/// request. +pub type Header = (String, String); + +/// Region placed in the credential scope. R2 ignores the value but requires a non-empty scope; +/// Cloudflare's documented convention is the literal string `"auto"`. Never varies by deployment, +/// so hardcoded. +const REGION: &str = "auto"; + +/// Holds the long-lived credentials and scope used to sign R2 requests. +#[derive(Clone)] +pub struct SigV4Signer { + access_key_id: String, + secret_access_key: String, + /// Always [`REGION`]. + region: String, + /// Always `"s3"` for R2. + service: String, +} + +impl std::fmt::Debug for SigV4Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the credentials. + f.debug_struct("SigV4Signer") + .field("access_key_id", &"[redacted]") + .field("secret_access_key", &"[redacted]") + .field("region", &self.region) + .field("service", &self.service) + .finish() + } +} + +impl SigV4Signer { + /// Builds a signer from bucket-scoped R2 credentials. + pub fn new(access_key_id: String, secret_access_key: String) -> Self { + Self { + access_key_id, + secret_access_key, + region: REGION.to_string(), + service: "s3".to_string(), + } + } + + /// Signs a request and returns the complete set of headers to attach to it. + /// + /// `host` is the request host with no scheme or trailing slash (e.g. + /// `.r2.cloudflarestorage.com`). `canonical_uri` is the absolute, already + /// percent-encoded request path (see [`encode_uri_path`]). `extra_headers` are additional + /// lowercase headers that must be covered by the signature — typically the `x-amz-meta-*` + /// custom-metadata headers; their names must be lowercase and they must also be sent on the + /// wire exactly as signed. + /// + /// The returned vector contains `extra_headers` plus the three computed headers + /// (`x-amz-date`, `x-amz-content-sha256`, `authorization`); the caller attaches every entry to + /// the outgoing request. + // The parameters mirror the inputs to the SigV4 canonical request; bundling them into a struct + // would only add indirection at the single call site in `client::put_object`. + #[allow(clippy::too_many_arguments)] + pub fn sign( + &self, + method: &str, + host: &str, + canonical_uri: &str, + canonical_query: &str, + extra_headers: &[Header], + payload: &[u8], + now: DateTime, + ) -> Vec
{ + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + let date_stamp = now.format("%Y%m%d").to_string(); + let payload_hash = hex::encode(Sha256::digest(payload)); + + // Assemble the full signed-header set: host + the two amz headers + caller extras. + let mut headers: Vec
= Vec::with_capacity(extra_headers.len() + 3); + headers.push(("host".to_string(), host.to_string())); + headers.push(("x-amz-content-sha256".to_string(), payload_hash.clone())); + headers.push(("x-amz-date".to_string(), amz_date.clone())); + headers.extend(extra_headers.iter().cloned()); + // Canonical headers are sorted by lowercase name; values are trimmed. + headers.sort_by(|a, b| a.0.cmp(&b.0)); + + let canonical_headers: String = + headers.iter().map(|(k, v)| format!("{k}:{}\n", v.trim())).collect(); + let signed_headers: String = + headers.iter().map(|(k, _)| k.as_str()).collect::>().join(";"); + + let canonical_request = format!( + "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}" + ); + + let credential_scope = + format!("{date_stamp}/{}/{}/{REQUEST_TYPE}", self.region, self.service); + let string_to_sign = format!( + "{ALGORITHM}\n{amz_date}\n{credential_scope}\n{}", + hex::encode(Sha256::digest(canonical_request.as_bytes())) + ); + + let signing_key = self.signing_key(&date_stamp); + let signature = hex::encode(hmac(&signing_key, string_to_sign.as_bytes())); + + let authorization = format!( + "{ALGORITHM} Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}", + self.access_key_id + ); + + // Return exactly the signed set minus `host` (the HTTP client sets it from the URL) plus + // the computed authorization — reusing the signed list makes sent == signed by + // construction. + let mut out: Vec
= headers.into_iter().filter(|(name, _)| name != "host").collect(); + out.push(("authorization".to_string(), authorization)); + out + } + + /// Derives the `SigV4` signing key for the given date via the four-step HMAC chain. + fn signing_key(&self, date_stamp: &str) -> Vec { + let k_date = + hmac(format!("AWS4{}", self.secret_access_key).as_bytes(), date_stamp.as_bytes()); + let k_region = hmac(&k_date, self.region.as_bytes()); + let k_service = hmac(&k_region, self.service.as_bytes()); + hmac(&k_service, REQUEST_TYPE.as_bytes()) + } +} + +/// Computes `HMAC-SHA256(key, data)`. +fn hmac(key: &[u8], data: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any size"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +/// Percent-encodes an object key into a `SigV4` canonical URI path. +/// +/// Each `/`-delimited segment is encoded with the RFC 3986 unreserved set preserved, then the +/// segments are rejoined with `/`. A leading `/` is always present. R2 object keys produced by the +/// uploaders (`block//.`, `attr/...`, `num/...`) are already within the unreserved +/// set, but this keeps the signer correct for any key. +pub fn encode_uri_path(bucket: &str, key: &str) -> String { + let mut path = String::from("/"); + path.push_str(&encode_segment(bucket)); + for segment in key.split('/') { + path.push('/'); + path.push_str(&encode_segment(segment)); + } + path +} + +/// Percent-encodes a single path segment with the `SigV4` unreserved set. +fn encode_segment(segment: &str) -> String { + percent_encoding::utf8_percent_encode(segment, URI_SEGMENT).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// AWS-published test vector for the `SigV4` signing-key derivation. + /// + /// From : + /// secret `wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY`, date `20150830`, region `us-east-1`, + /// service `iam` yields the documented signing key. + #[test] + fn signing_key_matches_aws_reference_vector() { + let signer = SigV4Signer { + access_key_id: "AKIDEXAMPLE".to_string(), + secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".to_string(), /* pragma: allowlist secret */ + region: "us-east-1".to_string(), + service: "iam".to_string(), + }; + let key = signer.signing_key("20150830"); + assert_eq!( + hex::encode(key), + "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9" + ); + } + + #[test] + fn encode_uri_path_preserves_unreserved_and_separators() { + // Witness keys only contain unreserved characters plus `/`, so they pass through unchanged. + let path = encode_uri_path("witness-testnet", "block/2000_2999/2045.0x23758c4d28eed6"); + assert_eq!(path, "/witness-testnet/block/2000_2999/2045.0x23758c4d28eed6"); + } + + #[test] + fn encode_uri_path_escapes_reserved_characters() { + // Defensive: a space and a colon must be percent-encoded, the `/` separators must not. + let path = encode_uri_path("b", "a b/c:d"); + assert_eq!(path, "/b/a%20b/c%3Ad"); + } + + #[test] + fn sign_produces_authorization_and_amz_headers() { + let signer = SigV4Signer::new("access".to_string(), "secret".to_string()); + let now = DateTime::parse_from_rfc3339("2026-06-13T12:00:00Z").unwrap().with_timezone(&Utc); + let headers = signer.sign( + "PUT", + "acc.r2.cloudflarestorage.com", + "/witness-testnet/block/2000_2999/2045.0xabc", + "", + &[("x-amz-meta-compression".to_string(), "zstd".to_string())], + b"payload", + now, + ); + + let content_sha = headers + .iter() + .find(|(k, _)| k == "x-amz-content-sha256") + .map(|(_, v)| v.clone()) + .expect("content sha header present"); + assert_eq!(content_sha, hex::encode(Sha256::digest(b"payload"))); + + let auth = headers + .iter() + .find(|(k, _)| k == "authorization") + .map(|(_, v)| v.clone()) + .expect("authorization header present"); + // Credential scope, the signed-header list (sorted, includes the meta header), and a + // signature must all be present. + assert!( + auth.starts_with("AWS4-HMAC-SHA256 Credential=access/20260613/auto/s3/aws4_request") + ); + assert!( + auth.contains( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-meta-compression" + ) + ); + assert!(auth.contains("Signature=")); + // The custom-metadata header is echoed back for the caller to send. + assert!(headers.iter().any(|(k, v)| k == "x-amz-meta-compression" && v == "zstd")); + // `host` is not returned (the HTTP client sets it from the URL). + assert!(!headers.iter().any(|(k, _)| k == "host")); + } + + /// Golden wire vector: the byte-exact header set for a complete signed request, computed with + /// an independent SigV4 implementation (Python `hashlib`/`hmac` over the AWS-documented + /// algorithm) — not with this code — so the signer is certified against the algorithm rather + /// than against itself. Any change to canonicalization, header ordering, credential scope, or + /// the HMAC chain flips the pinned signature. + #[test] + fn sign_matches_independent_golden_vector() { + let signer = SigV4Signer::new("access".to_string(), "secret".to_string()); + let now = DateTime::parse_from_rfc3339("2026-06-13T12:00:00Z").unwrap().with_timezone(&Utc); + let headers = signer.sign( + "PUT", + "acc.r2.cloudflarestorage.com", + "/witness-testnet/block/2000_2999/2045.0xabc", + "", + &[("x-amz-meta-compression".to_string(), "zstd".to_string())], + b"payload", + now, + ); + + let expected = [ + ( + "x-amz-content-sha256", + "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5", + ), + ("x-amz-date", "20260613T120000Z"), + ("x-amz-meta-compression", "zstd"), + ( + "authorization", + "AWS4-HMAC-SHA256 Credential=access/20260613/auto/s3/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-meta-compression, \ + Signature=dbbe53136588499c6798a928641af52e8dedf930a8cdd20cf138d4f8281fb167", + ), + ]; + assert_eq!(headers.len(), expected.len()); + for (name, value) in expected { + assert_eq!( + headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()), + Some(value), + "header {name} mismatch", + ); + } + } +}